mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd32962f7 | ||
|
|
d00bd666a7 | ||
|
|
4f047832ac | ||
|
|
a3c3d25592 | ||
|
|
b252cd98d2 | ||
|
|
670e6a6fdd | ||
|
|
e9c3753466 | ||
|
|
9c1bea7135 |
@@ -1,121 +0,0 @@
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,43 +0,0 @@
|
||||
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?
|
||||
@@ -1,43 +0,0 @@
|
||||
<!--
|
||||
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
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -35,13 +35,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -57,7 +51,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -92,7 +86,7 @@ jobs:
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
@@ -124,7 +118,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -149,7 +143,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -171,7 +165,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -214,7 +208,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
@@ -238,7 +232,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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
|
||||
@@ -13,9 +13,6 @@ on:
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -28,9 +25,6 @@ on:
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,35 +32,28 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates (main push only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
- 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: |
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -86,4 +86,3 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
+1
-46
@@ -1,54 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
## 0.1.70-beta.1 - 2026-05-07
|
||||
|
||||
### 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.
|
||||
@@ -69,8 +26,6 @@
|
||||
|
||||
### 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))
|
||||
|
||||
@@ -15,32 +15,19 @@ This is an npm workspace monorepo:
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Docs
|
||||
## Documentation
|
||||
|
||||
`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 |
|
||||
| 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 |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -75,19 +62,12 @@ 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`
|
||||
- **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.
|
||||
- **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?"
|
||||
|
||||
## Platform gating
|
||||
|
||||
|
||||
+129
-24
@@ -1,24 +1,31 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
|
||||
## How this project works
|
||||
|
||||
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
|
||||
- **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.
|
||||
This means:
|
||||
|
||||
## Reporting bugs
|
||||
- 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.
|
||||
|
||||
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.
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
|
||||
- **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.
|
||||
## 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.
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
@@ -28,36 +35,134 @@ Fill in the bug report form. The fields are there because asking back for the su
|
||||
|
||||
## What is most helpful
|
||||
|
||||
- bug fixes (especially Windows and Linux)
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging and platform fixes
|
||||
- focused UX improvements that fit the product direction
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing 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
|
||||
```
|
||||
|
||||
[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).
|
||||
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
|
||||
## Pull requests
|
||||
## Multi-platform testing
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
+10
-11
@@ -19,19 +19,18 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
### How it works
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Why the relay can't attack you
|
||||
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **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.
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **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.
|
||||
@@ -42,11 +41,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`. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -56,7 +55,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 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`.
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
|
||||
## 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.70", // see gotcha #1
|
||||
appVersion: "0.1.54", // 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.70",
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
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.70",
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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,8 +53,6 @@ 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
|
||||
|
||||
+45
-102
@@ -51,51 +51,39 @@ 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 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| 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) |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- `Stream` model handles timeline with 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. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
Commander.js CLI with Docker-style commands:
|
||||
|
||||
- `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 agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- `paseo speech …`
|
||||
- `paseo worktree ls/archive`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
@@ -103,7 +91,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- ECDH key exchange + AES-256-GCM 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
|
||||
@@ -125,48 +113,30 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## 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`.
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
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 }
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
```
|
||||
|
||||
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:**
|
||||
**Message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `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`
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
**Binary multiplexing:**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
|
||||
- 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.
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
@@ -185,44 +155,26 @@ Example: adding a new enum value
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
|
||||
```
|
||||
initializing → idle ⇄ running
|
||||
↓ ↓ ↓
|
||||
error
|
||||
↓
|
||||
closed
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
- `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
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
|
||||
All providers:
|
||||
|
||||
@@ -243,21 +195,12 @@ All providers:
|
||||
|
||||
## Storage
|
||||
|
||||
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── 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)
|
||||
└── daemon.log # Daemon trace logs
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
+165
-75
@@ -1,98 +1,188 @@
|
||||
# Coding Standards
|
||||
|
||||
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).
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
|
||||
## Core principles
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
## Comments and noise
|
||||
## Type hygiene
|
||||
|
||||
- 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.
|
||||
### Infer from schemas
|
||||
|
||||
## Confidence: commit to a shape
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
|
||||
- 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.
|
||||
```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> };
|
||||
|
||||
## Types
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
|
||||
- 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.
|
||||
### Named types over inline
|
||||
|
||||
## Errors
|
||||
No complex inline types in public function signatures.
|
||||
|
||||
- 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.
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
|
||||
## Density
|
||||
// Good
|
||||
interface EnqueueJobInput {
|
||||
userId: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
}
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
|
||||
- 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.
|
||||
### Object parameters
|
||||
|
||||
## Structure and modules
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
|
||||
- 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.
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
|
||||
## Refactoring is a bolt-on test
|
||||
// Good: object param
|
||||
interface CreateToolCallInput {
|
||||
provider: string;
|
||||
toolName: string;
|
||||
payload: unknown;
|
||||
}
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
|
||||
- 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.
|
||||
### One canonical type per concept
|
||||
|
||||
## React
|
||||
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.
|
||||
|
||||
- `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.
|
||||
```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 };
|
||||
|
||||
## Naming
|
||||
// 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 };
|
||||
```
|
||||
|
||||
- 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_.
|
||||
## 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
|
||||
|
||||
@@ -83,7 +83,6 @@ 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 },
|
||||
@@ -137,7 +136,6 @@ 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" },
|
||||
@@ -346,7 +344,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 @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
1. Install: `npm install -g @anthropic-ai/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:
|
||||
|
||||
@@ -552,7 +550,6 @@ 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 },
|
||||
@@ -567,7 +564,6 @@ 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" }
|
||||
|
||||
+56
-77
@@ -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. 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.
|
||||
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.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
@@ -11,12 +11,8 @@ 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/
|
||||
│ └── {sanitized-cwd}/
|
||||
│ └── {project-dir}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
@@ -30,8 +26,6 @@ $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
|
||||
@@ -40,29 +34,28 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
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 `{}`). `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 |
|
||||
| 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 |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
@@ -121,7 +114,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string \| null` |
|
||||
| `value` | `string?` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
@@ -137,11 +130,10 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
@@ -151,10 +143,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
// 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>
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
}
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
@@ -176,7 +170,7 @@ All fields are optional with sensible defaults.
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -261,7 +255,7 @@ Single file containing all rooms and messages.
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
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.
|
||||
Single file containing an array of all loop records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
@@ -271,12 +265,10 @@ Single file containing an array of all loop records. Writes are direct (not atom
|
||||
| `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 |
|
||||
@@ -352,15 +344,15 @@ Single file containing an array of all loop records. Writes are direct (not atom
|
||||
|
||||
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 \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -370,16 +362,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 \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -393,20 +385,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
}
|
||||
```
|
||||
|
||||
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`. |
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# 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` |
|
||||
+47
-221
@@ -1,247 +1,73 @@
|
||||
# Design
|
||||
# Designing Features
|
||||
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
How to think through a feature before writing code.
|
||||
|
||||
---
|
||||
## Start from the user
|
||||
|
||||
## 1. Character
|
||||
Even for backend work, start from the user's perspective:
|
||||
|
||||
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.
|
||||
- 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?
|
||||
|
||||
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_.
|
||||
## Map existing code
|
||||
|
||||
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.
|
||||
Before designing anything new, understand what exists:
|
||||
|
||||
---
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [architecture.md](./architecture.md))
|
||||
- What types and data shapes are already defined?
|
||||
|
||||
## 2. Component reuse
|
||||
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.
|
||||
|
||||
A semantic element used in three or more places is a primitive. One of a kind is a screen.
|
||||
## Define verification before implementation
|
||||
|
||||
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`.
|
||||
Before designing the solution, define how you'll know it works:
|
||||
|
||||
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`).
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
|
||||
---
|
||||
## Design the shape
|
||||
|
||||
## 3. Hierarchy
|
||||
### Data
|
||||
|
||||
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`.
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
### Layers
|
||||
|
||||
- **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`).
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
|
||||
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.
|
||||
### Interactions
|
||||
|
||||
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.
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
|
||||
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.
|
||||
### Refactoring
|
||||
|
||||
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.
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
|
||||
---
|
||||
## Create a concrete plan
|
||||
|
||||
## 4. Buttons
|
||||
Once the design is clear:
|
||||
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
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
|
||||
|
||||
`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.
|
||||
## Principles
|
||||
|
||||
`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` |
|
||||
- **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
|
||||
|
||||
+50
-27
@@ -11,36 +11,55 @@
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`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.)
|
||||
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.
|
||||
|
||||
### PASEO_HOME
|
||||
### Running alongside the main checkout
|
||||
|
||||
`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:
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
|
||||
```bash
|
||||
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=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
### Daemon endpoints
|
||||
- `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.
|
||||
|
||||
- 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.
|
||||
### Default ports
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
In the main checkout:
|
||||
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### 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.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
@@ -82,25 +101,29 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
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.
|
||||
### Relay → Daemon
|
||||
|
||||
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
|
||||
```bash
|
||||
npm run build:daemon
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
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.
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
### Server → CLI
|
||||
|
||||
- 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).
|
||||
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.
|
||||
|
||||
## CLI reference
|
||||
|
||||
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.
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -154,7 +177,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
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`.
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
|
||||
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,7 +15,6 @@ 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
|
||||
|
||||
|
||||
+34
-18
@@ -2,28 +2,44 @@
|
||||
|
||||
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 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.
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts: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`).
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts: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).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Open question — per-host project entry (TBD)
|
||||
|
||||
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
|
||||
|
||||
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
|
||||
|
||||
## Rename before landing (in-progress `Checkout*` references)
|
||||
|
||||
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
|
||||
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
|
||||
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
|
||||
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
|
||||
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
|
||||
|
||||
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/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`).
|
||||
- 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.
|
||||
|
||||
@@ -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 `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.
|
||||
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.
|
||||
|
||||
### Dropdowns that launch native presenters (iOS)
|
||||
|
||||
|
||||
+5
-9
@@ -67,14 +67,10 @@ 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 (May 2026)
|
||||
## Current state (March 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- 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
|
||||
- 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
|
||||
|
||||
+17
-31
@@ -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` 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.
|
||||
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.
|
||||
|
||||
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).
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
|
||||
### Direct
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 to `PROVIDER_CLIENT_FACTORIES`:
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
@@ -161,13 +161,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
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`):
|
||||
@@ -189,18 +187,19 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
|
||||
}
|
||||
```
|
||||
|
||||
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):
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
|
||||
```ts
|
||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
// ... existing entries ...
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
@@ -220,25 +219,25 @@ export const agentConfigs = {
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
(await isCommandAvailable("my-agent-binary")) &&
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
Add to the `allProviders` array:
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"pi",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
@@ -259,9 +258,7 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
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:
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
@@ -270,19 +267,16 @@ 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 }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -293,7 +287,6 @@ 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;
|
||||
@@ -303,10 +296,7 @@ interface AgentSession {
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -314,10 +304,6 @@ 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;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+2
-7
@@ -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.
|
||||
- **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.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- 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 Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
@@ -147,16 +147,11 @@ 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,37 +98,6 @@ 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.
|
||||
|
||||
+18
-43
@@ -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 (`packages/app/src/panels/agent-panel.tsx`)
|
||||
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
|
||||
- `WorkspaceScreen` shell, tabs row, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
@@ -124,9 +124,18 @@ 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.
|
||||
|
||||
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.
|
||||
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-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.
|
||||
```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.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
@@ -146,7 +155,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 }}`. `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:
|
||||
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:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz {
|
||||
@@ -222,49 +231,15 @@ 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.
|
||||
|
||||
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
|
||||
```tsx
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
|
||||
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||
```
|
||||
|
||||
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.
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+355
-578
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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({
|
||||
category: "attach-issue",
|
||||
prefix: "paseo-e2e-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({
|
||||
category: "attach-pr",
|
||||
prefix: "paseo-e2e-attach-pr-",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
|
||||
@@ -414,19 +414,15 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
// 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.",
|
||||
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.",
|
||||
);
|
||||
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)"}`,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
@@ -33,9 +34,9 @@ interface ArchiveTabDaemonClient {
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
predicate: (snapshot: { status: string; createdAt: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
): Promise<{ status: string; createdAt: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
@@ -132,6 +133,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
createdAt: snapshot.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
|
||||
}
|
||||
|
||||
export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
const sessionsButton = page.getByTestId("sidebar-history");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
|
||||
@@ -153,19 +153,14 @@ 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: {
|
||||
category: string;
|
||||
prefix?: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { category, prs = [], issues = [] } = options;
|
||||
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
|
||||
const repoName = `${prefix}${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
|
||||
@@ -170,12 +170,9 @@ export async function openNewWorkspaceComposer(
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
input: { projectKey: string; projectDisplayName: 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 "@/git/pr-pane-data";
|
||||
import { getStateLabel } from "@/utils/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
|
||||
@@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("sidebar-history")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
@@ -306,10 +306,6 @@ 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({
|
||||
category: "pr-pane",
|
||||
prefix: "paseo-e2e-pr-",
|
||||
prs: [
|
||||
{ title: "Review selected start ref", state: "open" },
|
||||
{ title: "Merged feature branch", state: "merged" },
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabVisible,
|
||||
type ArchiveTabAgent,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
type OpenedProject,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { expectSidebarWorkspaceSelected, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface SeededSidebarSessions {
|
||||
firstWorkspace: OpenedProject;
|
||||
secondWorkspace: OpenedProject;
|
||||
firstProjectName: string;
|
||||
secondProjectName: string;
|
||||
unmappedProjectName: string;
|
||||
agentsByNewest: [ArchiveTabAgent, ArchiveTabAgent, ArchiveTabAgent];
|
||||
unmappedAgent: ArchiveTabAgent;
|
||||
cleanupRepos: () => Promise<void>;
|
||||
}
|
||||
|
||||
function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function uniqueTitle(label: string): string {
|
||||
return `sidebar-history-${label}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function checkoutBranch(repoPath: string, branch: string): void {
|
||||
execSync(`git checkout ${JSON.stringify(branch)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedSidebarSessionsJourney(clients: {
|
||||
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
}): Promise<SeededSidebarSessions> {
|
||||
const firstRepo = await createTempGitRepo("sidebar-history-a-", { branches: ["feature-a"] });
|
||||
const secondRepo = await createTempGitRepo("sidebar-history-b-", { branches: ["feature-b"] });
|
||||
const unmappedRepo = await createTempGitRepo("sidebar-history-unmapped-");
|
||||
const workspaceIds: string[] = [];
|
||||
const agentIds: string[] = [];
|
||||
|
||||
try {
|
||||
checkoutBranch(firstRepo.path, "feature-a");
|
||||
checkoutBranch(secondRepo.path, "feature-b");
|
||||
|
||||
const firstWorkspace = await openProjectViaDaemon(clients.workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(clients.workspaceClient, secondRepo.path);
|
||||
workspaceIds.push(firstWorkspace.workspaceId, secondWorkspace.workspaceId);
|
||||
|
||||
const oldest = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: uniqueTitle("oldest"),
|
||||
});
|
||||
await sleep(20);
|
||||
const middle = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: uniqueTitle("middle"),
|
||||
});
|
||||
await sleep(20);
|
||||
const newest = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: secondRepo.path,
|
||||
title: uniqueTitle("newest"),
|
||||
});
|
||||
const unmappedAgent = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: unmappedRepo.path,
|
||||
title: uniqueTitle("unmapped"),
|
||||
});
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, unmappedRepo.path);
|
||||
agentIds.push(oldest.id, middle.id, newest.id, unmappedAgent.id);
|
||||
|
||||
return {
|
||||
firstWorkspace,
|
||||
secondWorkspace,
|
||||
firstProjectName: path.basename(firstRepo.path),
|
||||
secondProjectName: path.basename(secondRepo.path),
|
||||
unmappedProjectName: path.basename(unmappedRepo.path),
|
||||
agentsByNewest: [newest, middle, oldest],
|
||||
unmappedAgent,
|
||||
cleanupRepos: async () => {
|
||||
await unmappedRepo.cleanup();
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(clients.archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, workspaceId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
await unmappedRepo.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSeededSidebarSessions(
|
||||
seeded: SeededSidebarSessions | null,
|
||||
clients: {
|
||||
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!seeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agent of seeded.agentsByNewest) {
|
||||
await archiveAgentFromDaemon(clients.archiveClient, agent.id).catch(() => undefined);
|
||||
}
|
||||
await archiveAgentFromDaemon(clients.archiveClient, seeded.unmappedAgent.id).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, seeded.unmappedAgent.cwd).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await archiveLocalWorkspaceFromDaemon(
|
||||
clients.workspaceClient,
|
||||
seeded.secondWorkspace.workspaceId,
|
||||
).catch(() => undefined);
|
||||
await archiveLocalWorkspaceFromDaemon(
|
||||
clients.workspaceClient,
|
||||
seeded.firstWorkspace.workspaceId,
|
||||
).catch(() => undefined);
|
||||
await seeded.cleanupRepos();
|
||||
}
|
||||
|
||||
async function openAppWithSeededWorkspaces(page: Page, seeded: SeededSidebarSessions) {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceTreeVisible(page, seeded);
|
||||
}
|
||||
|
||||
function expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded: SeededSidebarSessions) {
|
||||
const [newest, middle, oldest] = seeded.agentsByNewest;
|
||||
expect(new Date(newest.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(middle.createdAt).getTime(),
|
||||
);
|
||||
expect(new Date(middle.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(oldest.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectWorkspaceTreeVisible(page: Page, seeded: SeededSidebarSessions) {
|
||||
await expect(page.getByRole("button", { name: "Workspaces" })).toBeVisible();
|
||||
await expect(page.getByText(seeded.firstProjectName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.firstWorkspace.workspaceName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.secondProjectName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.secondWorkspace.workspaceName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Filter sessions" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function switchSidebarToSessions(page: Page) {
|
||||
await page.getByRole("button", { name: "Sessions" }).click();
|
||||
await expect(page.getByRole("button", { name: "Filter sessions" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function switchSidebarToWorkspaces(page: Page, seeded: SeededSidebarSessions) {
|
||||
await page.getByRole("button", { name: "Workspaces" }).click();
|
||||
await expectWorkspaceTreeVisible(page, seeded);
|
||||
for (const agent of seeded.agentsByNewest) {
|
||||
await expect(sessionRow(page, agent)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page, agent: ArchiveTabAgent) {
|
||||
return page.getByRole("button", { name: new RegExp(escapeRegex(agent.title)) });
|
||||
}
|
||||
|
||||
async function expectSidebarSessionRows(page: Page, agents: readonly ArchiveTabAgent[]) {
|
||||
for (const agent of agents) {
|
||||
await expect(sessionRow(page, agent)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function expectOnlySidebarSessionRows(
|
||||
page: Page,
|
||||
input: {
|
||||
visible: readonly ArchiveTabAgent[];
|
||||
hidden: readonly ArchiveTabAgent[];
|
||||
},
|
||||
) {
|
||||
await expectSidebarSessionRows(page, input.visible);
|
||||
for (const agent of input.hidden) {
|
||||
await expect(sessionRow(page, agent)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectUnmappedSessionHidden(page: Page, seeded: SeededSidebarSessions) {
|
||||
await expect(sessionRow(page, seeded.unmappedAgent)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Filter sessions" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegex(seeded.unmappedProjectName)}$`),
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectNewestSessionRowFirst(page: Page, newest: ArchiveTabAgent) {
|
||||
await expect(sessionRow(page, newest)).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await page.getByRole("button").allTextContents();
|
||||
return titles.find((title) => title.includes("sidebar-history-")) ?? "";
|
||||
})
|
||||
.toContain(newest.title);
|
||||
}
|
||||
|
||||
async function chooseSessionsFilter(page: Page, name: string | RegExp) {
|
||||
await page.getByRole("button", { name: "Filter sessions" }).click();
|
||||
await page.getByRole("button", { name }).click();
|
||||
}
|
||||
|
||||
async function chooseProjectSessionsFilter(page: Page, projectName: string) {
|
||||
await chooseSessionsFilter(page, new RegExp(`^${escapeRegex(projectName)}$`));
|
||||
}
|
||||
|
||||
async function openFirstWorkspace(page: Page, seeded: SeededSidebarSessions) {
|
||||
const serverId = requireServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.firstWorkspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: seeded.firstWorkspace.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateFromSessionRow(page: Page, agent: ArchiveTabAgent) {
|
||||
await sessionRow(page, agent).click();
|
||||
}
|
||||
|
||||
async function expectWorkspaceFocusedOnSession(
|
||||
page: Page,
|
||||
input: { workspaceId: string; agent: ArchiveTabAgent },
|
||||
) {
|
||||
const serverId = requireServerId();
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, input.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, input.agent.id);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${input.agent.id}`).filter({ visible: true }).first(),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openHistoryFromSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
||||
await expect(page).toHaveURL(buildHostSessionsRoute(requireServerId()), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function runSidebarHistorySessionsJourney(page: Page) {
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
let seeded: SeededSidebarSessions | null = null;
|
||||
|
||||
try {
|
||||
seeded = await seedSidebarSessionsJourney({ archiveClient, workspaceClient });
|
||||
expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded);
|
||||
const [newest, middle, oldest] = seeded.agentsByNewest;
|
||||
|
||||
await openAppWithSeededWorkspaces(page, seeded);
|
||||
await switchSidebarToSessions(page);
|
||||
await expectSidebarSessionRows(page, seeded.agentsByNewest);
|
||||
await expectUnmappedSessionHidden(page, seeded);
|
||||
await expectNewestSessionRowFirst(page, newest);
|
||||
await switchSidebarToWorkspaces(page, seeded);
|
||||
|
||||
await switchSidebarToSessions(page);
|
||||
await chooseProjectSessionsFilter(page, seeded.secondProjectName);
|
||||
await expectOnlySidebarSessionRows(page, {
|
||||
visible: [newest],
|
||||
hidden: [middle, oldest],
|
||||
});
|
||||
await chooseSessionsFilter(page, "All");
|
||||
await expectSidebarSessionRows(page, seeded.agentsByNewest);
|
||||
|
||||
await openFirstWorkspace(page, seeded);
|
||||
await switchSidebarToSessions(page);
|
||||
await navigateFromSessionRow(page, newest);
|
||||
await expectWorkspaceFocusedOnSession(page, {
|
||||
workspaceId: seeded.secondWorkspace.workspaceId,
|
||||
agent: newest,
|
||||
});
|
||||
|
||||
await openHistoryFromSidebar(page);
|
||||
} finally {
|
||||
await cleanupSeededSidebarSessions(seeded, { archiveClient, workspaceClient });
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Sidebar History Sessions journey", () => {
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("toggles sessions, filters by project, and opens a session tab", async ({ page }) => {
|
||||
await runSidebarHistorySessionsJourney(page);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page, test } from "./fixtures";
|
||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||
import { test } from "./fixtures";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
@@ -8,55 +8,6 @@ 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,
|
||||
@@ -75,32 +26,6 @@ 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,20 +25,17 @@ 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,
|
||||
build_number: build_number.to_s,
|
||||
filter: { "version" => build_number.to_s },
|
||||
includes: "preReleaseVersion",
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
if state == "VALID"
|
||||
valid_build = build
|
||||
break
|
||||
end
|
||||
break if state == "VALID"
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
@@ -48,30 +45,9 @@ 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,10 +1,10 @@
|
||||
appId: sh.paseo
|
||||
---
|
||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||
# Ensure sidebar is closed: if sidebar-history is visible, close it via swipe left
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
id: "sidebar-history"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
@@ -21,7 +21,7 @@ appId: sh.paseo
|
||||
|
||||
# Verify sidebar opened
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
id: "sidebar-history"
|
||||
- takeScreenshot: 01-sidebar-opened
|
||||
|
||||
# Close sidebar via swipe left
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.70-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -115,7 +115,7 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.75.0",
|
||||
"wrangler": "^4.59.1",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
|
||||
const { state, startHostRuntimeBootstrapMock } = vi.hoisted(() => {
|
||||
return {
|
||||
state: {
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
daemonStartError: null as string | null,
|
||||
daemonStartIsRunning: false,
|
||||
latestBootstrapState: null as HostRuntimeBootstrapState | null,
|
||||
},
|
||||
startHostRuntimeBootstrapMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/styles/unistyles", () => ({}));
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
PortalProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("expo-linking", () => ({ addEventListener: vi.fn(), getInitialURL: vi.fn() }));
|
||||
vi.mock("expo-notifications", () => ({
|
||||
addNotificationResponseReceivedListener: vi.fn(),
|
||||
getLastNotificationResponseAsync: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-router", () => ({
|
||||
Stack: Object.assign(() => null, { Protected: () => null, Screen: () => null }),
|
||||
useGlobalSearchParams: () => ({}),
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({ navigate: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
Gesture: { Pan: () => ({}) },
|
||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-keyboard-controller", () => ({
|
||||
KeyboardProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
Extrapolation: { CLAMP: "clamp" },
|
||||
interpolate: vi.fn(),
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
useSharedValue: (value: unknown) => ({ value }),
|
||||
}));
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? factory({ colors: { surface0: "#000", foreground: "#fff" } })
|
||||
: factory,
|
||||
},
|
||||
UnistylesRuntime: { setAdaptiveThemes: vi.fn(), setTheme: vi.fn() },
|
||||
useUnistyles: () => ({ theme: { colors: { surface0: "#000", foreground: "#fff" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/command-center", () => ({ CommandCenter: () => null }));
|
||||
vi.mock("@/components/worktree-setup-callout-source", () => ({
|
||||
WorktreeSetupCalloutSource: () => null,
|
||||
}));
|
||||
vi.mock("@/components/download-toast", () => ({ DownloadToast: () => null }));
|
||||
vi.mock("@/components/quitting-overlay", () => ({ QuittingOverlay: () => null }));
|
||||
vi.mock("@/components/keyboard-shortcuts-dialog", () => ({ KeyboardShortcutsDialog: () => null }));
|
||||
vi.mock("@/components/left-sidebar", () => ({ LeftSidebar: () => null }));
|
||||
vi.mock("@/components/project-picker-modal", () => ({ ProjectPickerModal: () => null }));
|
||||
vi.mock("@/components/workspace-setup-dialog", () => ({ WorkspaceSetupDialog: () => null }));
|
||||
vi.mock("@/components/workspace-shortcut-targets-subscriber", () => ({
|
||||
WorkspaceShortcutTargetsSubscriber: () => null,
|
||||
}));
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
getIsElectronRuntime: () => false,
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
vi.mock("@/constants/platform", () => ({ isNative: false, isWeb: true }));
|
||||
vi.mock("@/contexts/horizontal-scroll-context", () => ({
|
||||
HorizontalScrollProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useHorizontalScrollOptional: () => null,
|
||||
}));
|
||||
vi.mock("@/contexts/session-context", () => ({
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||
SidebarAnimationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useSidebarAnimation: () => ({
|
||||
animateToClose: vi.fn(),
|
||||
animateToOpen: vi.fn(),
|
||||
backdropOpacity: { value: 0 },
|
||||
gestureAnimatingRef: { current: false },
|
||||
isGesturing: { value: false },
|
||||
openGestureRef: {},
|
||||
translateX: { value: 0 },
|
||||
windowWidth: 320,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/contexts/sidebar-callout-context", () => ({
|
||||
SidebarCalloutProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/voice-context", () => ({
|
||||
VoiceProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/app/host-runtime-bootstrap", () => ({
|
||||
startDaemonIfGateAllows: vi.fn(),
|
||||
startHostRuntimeBootstrap: startHostRuntimeBootstrapMock,
|
||||
}));
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({ shouldUseDesktopDaemon: () => false }));
|
||||
vi.mock("@/desktop/electron/events", () => ({ listenToDesktopEvent: vi.fn() }));
|
||||
vi.mock("@/desktop/electron/window", () => ({ updateDesktopWindowControls: vi.fn() }));
|
||||
vi.mock("@/desktop/host", () => ({ getDesktopHost: () => null }));
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => ({ loadDesktopSettings: vi.fn() }));
|
||||
vi.mock("@/desktop/updates/rosetta-callout-source", () => ({ RosettaCalloutSource: () => null }));
|
||||
vi.mock("@/desktop/updates/update-callout-source", () => ({ UpdateCalloutSource: () => null }));
|
||||
vi.mock("@/hooks/use-active-worktree-new-action", () => ({ useActiveWorktreeNewAction: vi.fn() }));
|
||||
vi.mock("@/hooks/use-favicon-status", () => ({ useFaviconStatus: vi.fn() }));
|
||||
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: vi.fn() }));
|
||||
vi.mock("@/hooks/use-latched-boolean", () => ({ useLatchedBoolean: (value: boolean) => value }));
|
||||
vi.mock("@/hooks/use-open-project", () => ({ useOpenProject: () => vi.fn() }));
|
||||
vi.mock("@/hooks/use-settings", () => ({
|
||||
useAppSettings: () => ({
|
||||
isLoading: false,
|
||||
settings: { theme: "dark" },
|
||||
updateSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-stable-event", () => ({ useStableEvent: (fn: unknown) => fn }));
|
||||
vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace: vi.fn() }));
|
||||
vi.mock("@/keyboard/keyboard-action-dispatcher", () => ({
|
||||
keyboardActionDispatcher: { dispatch: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/polyfills/crypto", () => ({ polyfillCrypto: vi.fn() }));
|
||||
vi.mock("@/query/query-client", () => ({ queryClient: {} }));
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
boot: vi.fn(),
|
||||
getEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||
subscribeAll: () => vi.fn(),
|
||||
subscribeHostList: () => vi.fn(),
|
||||
}),
|
||||
useHostMutations: () => ({ upsertConnectionFromOfferUrl: vi.fn() }),
|
||||
useHostRuntimeClient: () => null,
|
||||
useHosts: () => [],
|
||||
}));
|
||||
vi.mock("@/runtime/daemon-start-service", () => ({
|
||||
getDaemonStartService: () => ({
|
||||
getLastError: () => state.daemonStartError,
|
||||
isRunning: () => state.daemonStartIsRunning,
|
||||
recordError: vi.fn(),
|
||||
start: vi.fn(),
|
||||
subscribe: () => vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/panel-store", () => ({ usePanelStore: vi.fn(() => vi.fn()) }));
|
||||
vi.mock("@/stores/session-store", () => ({
|
||||
useSessionStore: { getState: () => ({ sessions: {} }) },
|
||||
}));
|
||||
vi.mock("@/styles/theme", () => ({ THEME_TO_UNISTYLES: { dark: "dark" } }));
|
||||
vi.mock("@/utils/active-host", () => ({ resolveActiveHost: () => null }));
|
||||
vi.mock("@/utils/desktop-sidebar-toggle", () => ({
|
||||
toggleDesktopSidebarsWithCheckoutIntent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/utils/host-routes", () => ({
|
||||
buildHostRootRoute: (serverId: string) => `/h/${serverId}`,
|
||||
mapPathnameToServer: () => null,
|
||||
parseHostAgentRouteFromPathname: () => null,
|
||||
parseServerIdFromPathname: () => null,
|
||||
parseWorkspaceOpenIntent: () => null,
|
||||
}));
|
||||
vi.mock("@/utils/notification-routing", () => ({
|
||||
buildNotificationRoute: () => "/",
|
||||
resolveNotificationTarget: () => ({}),
|
||||
}));
|
||||
vi.mock("@/utils/os-notifications", () => ({
|
||||
WEB_NOTIFICATION_CLICK_EVENT: "notification-click",
|
||||
ensureOsNotificationPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/utils/workspace-execution", () => ({
|
||||
resolveWorkspaceIdByExecutionDirectory: () => null,
|
||||
}));
|
||||
vi.mock("@/utils/workspace-navigation", () => ({ prepareWorkspaceTab: vi.fn() }));
|
||||
|
||||
let useHostRuntimeBootstrapState: typeof import("./_layout").useHostRuntimeBootstrapState;
|
||||
|
||||
function BootstrapStateProbe() {
|
||||
state.latestBootstrapState = useHostRuntimeBootstrapState();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("HostRuntimeBootstrapProvider startup give-up timer", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.daemonStartError = null;
|
||||
state.daemonStartIsRunning = false;
|
||||
state.latestBootstrapState = null;
|
||||
startHostRuntimeBootstrapMock.mockClear();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderProvider() {
|
||||
const layout = await import("./_layout");
|
||||
useHostRuntimeBootstrapState = layout.useHostRuntimeBootstrapState;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<layout.HostRuntimeBootstrapProvider>
|
||||
<BootstrapStateProbe />
|
||||
</layout.HostRuntimeBootstrapProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("gives up after 5s when the env override host is not online", async () => {
|
||||
await renderProvider();
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(true);
|
||||
});
|
||||
|
||||
it("does not give up after 5s when an env override host is online", async () => {
|
||||
state.anyOnlineHostServerId = "server-local";
|
||||
|
||||
await renderProvider();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
default as React,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -59,18 +60,19 @@ 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";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
} 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";
|
||||
@@ -83,12 +85,13 @@ 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();
|
||||
|
||||
@@ -115,8 +118,25 @@ function PushNotificationRouter() {
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
router.navigate(buildNotificationRoute(data));
|
||||
@@ -292,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
@@ -307,16 +327,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||
const waitForConfiguredLocalDaemon =
|
||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
||||
|
||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
anyOnlineHostServerId ||
|
||||
daemonStartError ||
|
||||
daemonStartIsRunning ||
|
||||
waitForConfiguredLocalDaemon ||
|
||||
hasGivenUpWaitingForHost
|
||||
) {
|
||||
return;
|
||||
@@ -327,13 +343,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
anyOnlineHostServerId,
|
||||
daemonStartError,
|
||||
daemonStartIsRunning,
|
||||
waitForConfiguredLocalDaemon,
|
||||
hasGivenUpWaitingForHost,
|
||||
]);
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
@@ -869,20 +879,15 @@ 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 (
|
||||
<QueryProvider>
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</QueryProvider>
|
||||
</PortalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const headerTitle = typeof params.title === "string" ? params.title : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
headerTitle={headerTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -18,7 +19,9 @@ 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 { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
@@ -72,7 +75,6 @@ function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
attentionTimestamp: agent.attentionTimestamp,
|
||||
archivedAt: agent.archivedAt,
|
||||
labels: agent.labels,
|
||||
parentAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,13 +311,23 @@ 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);
|
||||
navigateToAgent({
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1541,6 +1541,7 @@ export function Composer({
|
||||
|
||||
<View style={styles.messageInputContainer}>
|
||||
{autocompletePopover}
|
||||
{attachmentPreviewList}
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
@@ -1580,7 +1581,6 @@ export function Composer({
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentPreviewList}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
@@ -1621,8 +1621,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
overflow: "visible",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
@@ -1685,16 +1684,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
imageThumbnailPlaceholder: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 48,
|
||||
height: 48,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
githubPillBody: {
|
||||
minHeight: 32,
|
||||
minHeight: 48,
|
||||
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 "@/git/pr-pane";
|
||||
import { PrPane } from "./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";
|
||||
|
||||
+1
-202
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||
|
||||
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
||||
return {
|
||||
@@ -8,10 +8,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
githubFeaturesEnabled: true,
|
||||
hasPullRequest: false,
|
||||
pullRequestUrl: null,
|
||||
pullRequestState: null,
|
||||
pullRequestIsDraft: false,
|
||||
pullRequestIsMerged: false,
|
||||
pullRequestMergeable: "UNKNOWN",
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
@@ -50,21 +46,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-pr-squash": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-pr-merge": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-pr-rebase": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
@@ -177,9 +158,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
@@ -235,183 +213,4 @@ describe("git-actions-policy", () => {
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Squash and merge",
|
||||
});
|
||||
});
|
||||
|
||||
it("respects the ship preference when choosing between PR merge and local merge", () => {
|
||||
const prPreferredActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
const localMergePreferredActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prPreferredActions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Squash and merge",
|
||||
});
|
||||
expect(localMergePreferredActions.primary).toMatchObject({
|
||||
id: "merge-branch",
|
||||
label: "Merge locally",
|
||||
});
|
||||
expect(
|
||||
localMergePreferredActions.secondary.some((action) => action.id === "merge-pr-squash"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the merge-pr actions in the feature branch menu", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses Merge locally for the local merge action", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
}),
|
||||
);
|
||||
const action = actions.secondary.find((entry) => entry.id === "merge-branch");
|
||||
|
||||
expect(action).toMatchObject({ label: "Merge locally" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"draft",
|
||||
{ pullRequestIsDraft: true },
|
||||
"Merge PR isn't available because the pull request is still a draft",
|
||||
],
|
||||
[
|
||||
"merged",
|
||||
{ pullRequestIsMerged: true },
|
||||
"Merge PR isn't available because the pull request is already merged",
|
||||
],
|
||||
[
|
||||
"closed",
|
||||
{ pullRequestState: "closed" as const },
|
||||
"Merge PR isn't available because the pull request is closed",
|
||||
],
|
||||
[
|
||||
"conflicting",
|
||||
{ pullRequestMergeable: "CONFLICTING" as const },
|
||||
"Merge PR isn't available because the pull request has conflicts",
|
||||
],
|
||||
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
const mergePrActions = actions.secondary.filter((action) =>
|
||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
);
|
||||
|
||||
expect(mergePrActions).toHaveLength(3);
|
||||
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
|
||||
message,
|
||||
message,
|
||||
message,
|
||||
]);
|
||||
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
isPaseoOwnedWorktree: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const allActions = [...actions.secondary];
|
||||
|
||||
const groupStarters = allActions
|
||||
.filter((action) => action.startsGroup)
|
||||
.map((action) => action.id);
|
||||
const nonGroupStarters = allActions
|
||||
.filter((action) => !action.startsGroup)
|
||||
.map((action) => action.id);
|
||||
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
|
||||
expect(nonGroupStarters).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+3
-122
@@ -1,7 +1,6 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
@@ -9,9 +8,6 @@ export type GitActionId =
|
||||
| "push"
|
||||
| "pull-and-push"
|
||||
| "pr"
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -25,8 +21,6 @@ export interface GitAction {
|
||||
status: ActionStatus;
|
||||
unavailableMessage?: string;
|
||||
icon?: ReactElement;
|
||||
/** When true, a menu separator should be rendered before this item. */
|
||||
startsGroup: boolean;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
@@ -48,10 +42,6 @@ export interface BuildGitActionsInput {
|
||||
githubFeaturesEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
pullRequestState: "open" | "closed" | null;
|
||||
pullRequestIsDraft: boolean;
|
||||
pullRequestIsMerged: boolean;
|
||||
pullRequestMergeable: PullRequestMergeable;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
@@ -68,29 +58,7 @@ export interface BuildGitActionsInput {
|
||||
}
|
||||
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||
const FEATURE_ACTION_IDS: GitActionId[] = [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
];
|
||||
|
||||
const MERGE_PR_METHODS: Record<
|
||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
{ label: string; method: CheckoutPrMergeMethod }
|
||||
> = {
|
||||
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
|
||||
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
|
||||
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
|
||||
};
|
||||
|
||||
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
|
||||
if (state === "open") return "open";
|
||||
if (state === "closed") return "closed";
|
||||
return null;
|
||||
}
|
||||
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
@@ -107,7 +75,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
disabled: input.runtime.commit.disabled,
|
||||
status: input.runtime.commit.status,
|
||||
icon: input.runtime.commit.icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime.commit.handler,
|
||||
});
|
||||
|
||||
@@ -120,7 +87,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
status: input.runtime.pull.status,
|
||||
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
|
||||
icon: input.runtime.pull.icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime.pull.handler,
|
||||
});
|
||||
|
||||
@@ -133,7 +99,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
status: input.runtime.push.status,
|
||||
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
|
||||
icon: input.runtime.push.icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime.push.handler,
|
||||
});
|
||||
|
||||
@@ -148,19 +113,14 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
? undefined
|
||||
: getPullAndPushUnavailableMessage(input),
|
||||
icon: input.runtime["pull-and-push"].icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime["pull-and-push"].handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
|
||||
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
|
||||
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: "Merge locally",
|
||||
label: `Merge into ${input.baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: input.runtime["merge-branch"].disabled,
|
||||
@@ -169,7 +129,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
? undefined
|
||||
: getMergeBranchUnavailableMessage(input),
|
||||
icon: input.runtime["merge-branch"].icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime["merge-branch"].handler,
|
||||
});
|
||||
|
||||
@@ -184,7 +143,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
? undefined
|
||||
: getMergeFromBaseUnavailableMessage(input),
|
||||
icon: input.runtime["merge-from-base"].icon,
|
||||
startsGroup: true,
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
@@ -200,7 +158,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
? undefined
|
||||
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
startsGroup: true,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
@@ -238,17 +195,11 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (canMergePr(input) && input.shipDefault === "pr") {
|
||||
return "merge-pr-squash";
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
|
||||
return "merge-branch";
|
||||
}
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||
return "pr";
|
||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -267,7 +218,6 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
? undefined
|
||||
: "View PR isn't available right now because GitHub isn't connected",
|
||||
icon: input.runtime.pr.icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
@@ -283,32 +233,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
? undefined
|
||||
: getCreatePrUnavailableMessage(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
startsGroup: false,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMergePrAction(
|
||||
input: BuildGitActionsInput,
|
||||
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
): GitAction {
|
||||
const runtime = input.runtime[id];
|
||||
const config = MERGE_PR_METHODS[id];
|
||||
const unavailableMessage = getMergePrUnavailableMessage(input);
|
||||
return {
|
||||
id,
|
||||
label: config.label,
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: runtime.disabled || shouldDisableMergePrAction(input),
|
||||
status: runtime.status,
|
||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||
icon: runtime.icon,
|
||||
startsGroup: id === "merge-pr-squash",
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function canPull(input: BuildGitActionsInput): boolean {
|
||||
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
|
||||
}
|
||||
@@ -326,22 +254,6 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function canMergePr(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
input.githubFeaturesEnabled &&
|
||||
input.hasPullRequest &&
|
||||
input.pullRequestState === "open" &&
|
||||
!input.pullRequestIsDraft &&
|
||||
!input.pullRequestIsMerged &&
|
||||
input.pullRequestMergeable === "MERGEABLE" &&
|
||||
input.aheadCount > 0 &&
|
||||
!input.hasUncommittedChanges &&
|
||||
input.behindOfOrigin === 0 &&
|
||||
input.aheadOfOrigin === 0 &&
|
||||
!canMergeFromBase(input)
|
||||
);
|
||||
}
|
||||
|
||||
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.hasRemote) {
|
||||
return "Pull isn't available here because this branch is not connected to a remote yet";
|
||||
@@ -416,34 +328,3 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.githubFeaturesEnabled) {
|
||||
return "Merge PR isn't available right now because GitHub isn't connected";
|
||||
}
|
||||
if (!input.hasPullRequest) {
|
||||
return "Merge PR isn't available because there isn't a pull request yet";
|
||||
}
|
||||
if (input.pullRequestIsDraft) {
|
||||
return "Merge PR isn't available because the pull request is still a draft";
|
||||
}
|
||||
if (input.pullRequestIsMerged) {
|
||||
return "Merge PR isn't available because the pull request is already merged";
|
||||
}
|
||||
if (input.pullRequestState === "closed") {
|
||||
return "Merge PR isn't available because the pull request is closed";
|
||||
}
|
||||
if (input.pullRequestMergeable === "CONFLICTING") {
|
||||
return "Merge PR isn't available because the pull request has conflicts";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
input.pullRequestIsDraft ||
|
||||
input.pullRequestIsMerged ||
|
||||
input.pullRequestState === "closed" ||
|
||||
input.pullRequestMergeable === "CONFLICTING"
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -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 "@/git/policy";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -163,7 +163,9 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
action={action}
|
||||
onSelect={handleActionSelect}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
needsSeparator={action.startsGroup}
|
||||
needsSeparator={
|
||||
action.id === "merge-from-base" || action.id === "archive-worktree"
|
||||
}
|
||||
showSeparator={index > 0}
|
||||
closeOnSelect={
|
||||
action.status === "idle" && action.id === "pr" && action.label === "View PR"
|
||||
+712
-29
@@ -25,6 +25,7 @@ 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 {
|
||||
@@ -44,16 +45,20 @@ 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 "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "@/components/diff-scroll";
|
||||
import { DiffScroll } from "./diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
@@ -61,7 +66,7 @@ import {
|
||||
} from "@getpaseo/highlight";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
@@ -78,12 +83,16 @@ 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 { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
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 { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
@@ -111,7 +120,11 @@ import {
|
||||
type InlineReviewActions,
|
||||
} from "@/review";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
|
||||
@@ -1261,6 +1274,16 @@ 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",
|
||||
@@ -1388,6 +1411,194 @@ 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;
|
||||
@@ -1431,6 +1642,35 @@ 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();
|
||||
@@ -1447,6 +1687,36 @@ 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,
|
||||
@@ -1466,6 +1736,128 @@ 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;
|
||||
}
|
||||
@@ -1478,10 +1870,13 @@ 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;
|
||||
@@ -1548,7 +1943,15 @@ export function GitDiffPane({
|
||||
error: statusError,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
|
||||
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
|
||||
const {
|
||||
gitStatus,
|
||||
isGit,
|
||||
notGit,
|
||||
statusErrorMessage,
|
||||
baseRef,
|
||||
hasUncommittedChanges,
|
||||
actionsDisabled,
|
||||
} = statusState;
|
||||
|
||||
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
||||
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
|
||||
@@ -1638,7 +2041,11 @@ export function GitDiffPane({
|
||||
setWorkspaceAttachments,
|
||||
workspaceAttachmentScopeKey,
|
||||
]);
|
||||
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
@@ -1672,6 +2079,45 @@ 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[] = [];
|
||||
@@ -1858,6 +2304,119 @@ 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") {
|
||||
@@ -1940,30 +2499,59 @@ 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,
|
||||
@@ -1995,6 +2583,101 @@ 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 { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||
import { FolderPlus, Settings } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -32,8 +32,9 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row";
|
||||
import { SidebarShellHeader } from "@/components/sidebar/sidebar-shell-header";
|
||||
import { SidebarWorkspacesView } from "@/components/sidebar/sidebar-workspaces-view";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -62,9 +63,14 @@ import {
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
} from "@/utils/host-routes";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import {
|
||||
SidebarSessionsToggle,
|
||||
SidebarSessionsView,
|
||||
type SidebarSessionFilter,
|
||||
type SidebarSessionViewMode,
|
||||
useSidebarSessionsController,
|
||||
} from "@/sidebar-sessions";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -80,6 +86,14 @@ interface SidebarSharedProps {
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: string;
|
||||
activeHostStatusColor: string;
|
||||
sidebarViewMode: SidebarSessionViewMode;
|
||||
setSidebarViewMode: (mode: SidebarSessionViewMode) => void;
|
||||
sidebarSessionFilter: SidebarSessionFilter;
|
||||
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
|
||||
groupByProject: boolean;
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
setGroupByProject: (next: boolean) => void;
|
||||
toggleProjectPreviewExpanded: (projectKey: string) => void;
|
||||
hostOptions: ComboboxOption[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
@@ -182,6 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
const {
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
} = useSidebarSessionsController({ serverId: activeServerId });
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
@@ -247,6 +271,14 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -485,6 +517,14 @@ function MobileSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -507,8 +547,6 @@ function MobileSidebar({
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -664,6 +702,30 @@ function MobileSidebar({
|
||||
],
|
||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||
);
|
||||
const toggleSlot = useMemo(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
groupByProject={groupByProject}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
onGroupByProjectChange={setGroupByProject}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
groupByProject,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setGroupByProject,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -672,29 +734,35 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<SidebarShellHeader toggleSlot={toggleSlot}>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
@@ -723,6 +791,14 @@ function DesktopSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -743,8 +819,6 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -799,6 +873,30 @@ function DesktopSidebar({
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
);
|
||||
const toggleSlot = useMemo(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
groupByProject={groupByProject}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
onGroupByProjectChange={setGroupByProject}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
groupByProject,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setGroupByProject,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -807,31 +905,37 @@ function DesktopSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<SidebarShellHeader
|
||||
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
|
||||
toggleSlot={toggleSlot}
|
||||
withTitlebarDragRegion
|
||||
>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
@@ -898,9 +1002,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -125,8 +125,6 @@ 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 {
|
||||
@@ -1096,7 +1094,6 @@ 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 {
|
||||
@@ -1136,7 +1133,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onFocusChange: props.onFocusChange,
|
||||
onHeightChange: props.onHeightChange,
|
||||
inputWrapperStyle: props.inputWrapperStyle,
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1184,7 +1180,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onFocusChange,
|
||||
onHeightChange,
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
const toast = useToast();
|
||||
@@ -1702,7 +1697,6 @@ 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,8 +66,13 @@ 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 { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -903,34 +908,17 @@ function MarkdownLink({
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClickCapture={preventAnchorNavigation}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={MARKDOWN_LINK_ANCHOR_STYLE}
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -2853,6 +2841,7 @@ 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>(() => {
|
||||
@@ -2869,36 +2858,63 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const presentation = useMemo(
|
||||
const displayDetail = useMemo<ToolCallDetail>(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
toolName,
|
||||
status,
|
||||
effectiveDetail ?? {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
[effectiveDetail],
|
||||
);
|
||||
|
||||
const displayModel = useMemo(
|
||||
() =>
|
||||
buildToolCallDisplayModel({
|
||||
name: toolName,
|
||||
status: status === "executing" ? "running" : status,
|
||||
error: error ?? null,
|
||||
detail: effectiveDetail,
|
||||
detail: displayDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
[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],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
if (!openFilePath || !onOpenFilePath) {
|
||||
if (!extractedFilePath || !onOpenFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
return () => onOpenFilePath(openFilePath);
|
||||
}, [presentation.openFilePath, onOpenFilePath]);
|
||||
return () => onOpenFilePath(extractedFilePath);
|
||||
}, [extractedFilePath, onOpenFilePath]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
toolName,
|
||||
displayName,
|
||||
summary: secondaryLabel,
|
||||
detail: effectiveDetail,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
@@ -2906,12 +2922,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2947,14 +2963,14 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
errorText={presentation.errorText}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
@@ -2968,13 +2984,13 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={presentation.displayName}
|
||||
secondaryLabel={presentation.summary}
|
||||
icon={presentation.icon}
|
||||
label={displayName}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
onOpenFile={handleOpenFile}
|
||||
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
MessageSquare,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data";
|
||||
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||
import type {
|
||||
CheckStatus,
|
||||
PrPaneActivity,
|
||||
PrPaneCheck,
|
||||
PrPaneData,
|
||||
PrState,
|
||||
} from "@/git/pr-pane-data";
|
||||
} from "@/utils/pr-pane-data";
|
||||
|
||||
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.row, Boolean(hovered) && styles.hoverable];
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -19,6 +20,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface PathRowProps {
|
||||
path: string;
|
||||
@@ -63,6 +65,7 @@ export function ProjectPickerModal() {
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
@@ -94,18 +97,15 @@ export function ProjectPickerModal() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
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 options = useMemo(
|
||||
() =>
|
||||
buildWorkingDirectorySuggestions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[query, directorySuggestionsQuery.data, recommendedPaths],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -118,6 +118,16 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, trimmed, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
@@ -126,7 +136,7 @@ export function ProjectPickerModal() {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, openProject, serverId, setOpen],
|
||||
[client, intent, openProject, serverId, setOpen],
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
@@ -242,8 +252,6 @@ export function ProjectPickerModal() {
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
editable={!isSubmitting}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSubmitCustom}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { lg: 8 },
|
||||
colors: {
|
||||
border: "#dddddd",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
iconSize: { md: 16 },
|
||||
shadow: { md: {} },
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
|
||||
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const collapsedFooter = <Text>Footer row</Text>;
|
||||
|
||||
describe("SidebarProjectSection", () => {
|
||||
it("renders the header and expanded children", () => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="collapse"
|
||||
isCollapsed={false}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(getByText("Project A")).toBeTruthy();
|
||||
expect(getByText("Child row")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the header visible and hides children plus footer when collapsed", () => {
|
||||
const { getByTestId, queryByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="expand"
|
||||
isCollapsed
|
||||
footer={collapsedFooter}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(queryByText("Child row")).toBeNull();
|
||||
expect(queryByText("Footer row")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import {
|
||||
SidebarProjectIcon,
|
||||
SidebarProjectRowVisual,
|
||||
} from "@/components/sidebar/sidebar-project-row-visual";
|
||||
|
||||
export type SidebarProjectChevronDirection = "expand" | "collapse";
|
||||
|
||||
export interface SidebarProjectHeaderRowProps {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
onPress: () => void;
|
||||
isHovered: boolean;
|
||||
|
||||
/** Visual collapsed state. When set, drives the chevron direction (down=collapse / right=expand). */
|
||||
chevron?: SidebarProjectChevronDirection | null;
|
||||
|
||||
selected?: boolean;
|
||||
isDragging?: boolean;
|
||||
|
||||
/** Replaces the chevron+icon leading visual entirely (e.g. archiving spinner, workspace status icon). */
|
||||
leadingVisualOverride?: ReactElement | null;
|
||||
|
||||
/** Appended after the project name. Kebab, new-worktree button, shortcut badge. */
|
||||
trailingSlot?: ReactNode;
|
||||
|
||||
/** Long-press / drag handlers. */
|
||||
onPressIn?: (event: GestureResponderEvent) => void;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onPressOut?: (event: GestureResponderEvent) => void;
|
||||
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/** Substitute for the inner Pressable (e.g. ContextMenuTrigger). Receives the same props. */
|
||||
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
|
||||
}
|
||||
|
||||
export interface SidebarProjectSectionProps {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
onPress: () => void;
|
||||
isHovered: boolean;
|
||||
chevron: "expand" | "collapse" | null;
|
||||
selected?: boolean;
|
||||
isDragging?: boolean;
|
||||
trailingSlot?: ReactNode;
|
||||
testID?: string;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
isCollapsed: boolean;
|
||||
headerProps?: Pick<
|
||||
SidebarProjectHeaderRowProps,
|
||||
| "accessibilityLabel"
|
||||
| "leadingVisualOverride"
|
||||
| "onPressIn"
|
||||
| "onTouchMove"
|
||||
| "onPressOut"
|
||||
| "PressableComponent"
|
||||
>;
|
||||
}
|
||||
|
||||
export const SidebarProjectSection = memo(function SidebarProjectSection({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
onPress,
|
||||
isHovered,
|
||||
chevron,
|
||||
selected = false,
|
||||
isDragging = false,
|
||||
trailingSlot = null,
|
||||
testID,
|
||||
children = null,
|
||||
footer = null,
|
||||
isCollapsed,
|
||||
headerProps,
|
||||
}: SidebarProjectSectionProps): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<SidebarProjectHeaderRow
|
||||
projectName={projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
chevron={chevron}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
selected={selected}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
{...headerProps}
|
||||
/>
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
{children}
|
||||
{footer}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared chrome for a project row that names a section: pressable, hover/press/selected
|
||||
* styles, on-hover chevron leading visual, and a trailing actions slot.
|
||||
*
|
||||
* Hover is controlled — callers wrap with their own outer View carrying onPointerEnter/Leave
|
||||
* (workspace tree adds drag-handle attrs to that wrapper; sessions wraps a plain View).
|
||||
*/
|
||||
export const SidebarProjectHeaderRow = memo(
|
||||
forwardRef<View, SidebarProjectHeaderRowProps>(function SidebarProjectHeaderRow(
|
||||
{
|
||||
projectName,
|
||||
iconDataUri,
|
||||
onPress,
|
||||
isHovered,
|
||||
chevron = null,
|
||||
selected = false,
|
||||
isDragging = false,
|
||||
leadingVisualOverride = null,
|
||||
trailingSlot = null,
|
||||
onPressIn,
|
||||
onTouchMove,
|
||||
onPressOut,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
PressableComponent = Pressable,
|
||||
},
|
||||
ref,
|
||||
): ReactElement {
|
||||
const showChevron = isHovered && chevron !== null;
|
||||
const leadingVisual = useMemo<ReactElement | null>(() => {
|
||||
if (showChevron && chevron) {
|
||||
return (
|
||||
<View style={sidebarProjectHeaderRowStyles.leadingSlot}>
|
||||
<SidebarProjectChevron chevron={chevron} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (leadingVisualOverride) {
|
||||
return leadingVisualOverride;
|
||||
}
|
||||
return <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />;
|
||||
}, [chevron, iconDataUri, leadingVisualOverride, projectName, showChevron]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
sidebarProjectHeaderRowStyles.row,
|
||||
isDragging && sidebarProjectHeaderRowStyles.dragging,
|
||||
selected && sidebarProjectHeaderRowStyles.selected,
|
||||
isHovered && sidebarProjectHeaderRowStyles.hovered,
|
||||
pressed && sidebarProjectHeaderRowStyles.pressed,
|
||||
],
|
||||
[isDragging, isHovered, selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<PressableComponent
|
||||
ref={ref}
|
||||
style={pressableStyle}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onTouchMove={onTouchMove}
|
||||
onPressOut={onPressOut}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
<SidebarProjectRowVisual
|
||||
projectName={projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
leadingVisual={leadingVisual}
|
||||
/>
|
||||
{trailingSlot}
|
||||
</PressableComponent>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
function SidebarProjectChevron({
|
||||
chevron,
|
||||
}: {
|
||||
chevron: SidebarProjectChevronDirection;
|
||||
}): ReactElement {
|
||||
if (chevron === "collapse") {
|
||||
return <ChevronDown size={14} color="#9ca3af" />;
|
||||
}
|
||||
return <ChevronRight size={14} color="#9ca3af" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer wrapper that carries hover state for a `<SidebarProjectHeaderRow>` plus extra props
|
||||
* (drag handle attrs, ref). Sessions uses the lighter `<SidebarProjectHeaderHoverWrap>`.
|
||||
*/
|
||||
export const SidebarProjectHeaderHoverWrap = forwardRef<
|
||||
View,
|
||||
ViewProps & {
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
}
|
||||
>(function SidebarProjectHeaderHoverWrap({ onHoverChange, children, ...rest }, ref): ReactElement {
|
||||
const handleEnter = useCallback(() => onHoverChange(true), [onHoverChange]);
|
||||
const handleLeave = useCallback(() => onHoverChange(false), [onHoverChange]);
|
||||
return (
|
||||
<View ref={ref} {...rest} onPointerEnter={handleEnter} onPointerLeave={handleLeave}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export const sidebarProjectHeaderRowStyles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
hovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
dragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
leadingSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indent style for rows nested under a `<SidebarProjectHeaderRow>`. Workspace rows and
|
||||
* grouped-session rows both apply this so children visually align across surfaces.
|
||||
*/
|
||||
export const sidebarProjectChildIndentStyles = StyleSheet.create((theme) => ({
|
||||
childRow: {
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
}));
|
||||
@@ -108,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { usePathname } from "expo-router";
|
||||
import { History } from "lucide-react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
|
||||
interface SidebarHistoryNavigationRowProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function SidebarHistoryNavigationRow({ onPress }: SidebarHistoryNavigationRowProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label="History"
|
||||
onPress={onPress}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-history"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { memo, useMemo, type ReactElement } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
|
||||
export interface SidebarProjectRowVisualProps {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
leadingVisual?: ReactElement | null;
|
||||
}
|
||||
|
||||
export const SidebarProjectRowVisual = memo(function SidebarProjectRowVisual({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
leadingVisual = null,
|
||||
}: SidebarProjectRowVisualProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.projectRowLeft}>
|
||||
{leadingVisual ?? <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />}
|
||||
<SidebarProjectName projectName={projectName} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export const SidebarProjectIcon = memo(function SidebarProjectIcon({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
}: {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
}): ReactElement {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{iconDataUri ? (
|
||||
<Image source={imageSource} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarProjectName = memo(function SidebarProjectName({
|
||||
projectName,
|
||||
}: {
|
||||
projectName: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<View style={styles.projectTitleGroup}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
projectRowLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIcon: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
projectTitleGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Pressable, View } from "react-native";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} from "./sidebar-project-trailing-actions";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: navigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { md: 6 },
|
||||
colors: {
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
fontSize: { sm: 14 },
|
||||
spacing: { 2: 8 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<View testID={testID}>{children}</View>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => <View />,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||
useShortcutKeys: () => null,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
|
||||
onPress: () => {},
|
||||
accessibilityLabel: "New workspace",
|
||||
testID: "sidebar-project-new-worktree-project-a",
|
||||
tooltipLabel: "New workspace",
|
||||
icon: "folder-plus",
|
||||
};
|
||||
|
||||
describe("SidebarProjectTrailingActions", () => {
|
||||
it("renders the kebab and create button when configured", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={newWorkspaceCreateButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders only the kebab when createButton is null", () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={null}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the create button override instead of the default route", () => {
|
||||
const createOnPress = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const createButton = React.useMemo(
|
||||
() => ({
|
||||
onPress: createOnPress,
|
||||
accessibilityLabel: "New session",
|
||||
testID: "sidebar-project-project-a-new-session-button",
|
||||
tooltipLabel: "New session",
|
||||
icon: "plus" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={createButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByTestId } = render(<Harness />);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||
|
||||
expect(createOnPress).toHaveBeenCalledOnce();
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import React, { useCallback, useState, type ReactElement } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export interface SidebarProjectCreateButtonConfig {
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
showShortcutHint?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectTrailingActionsProps {
|
||||
projectKey: string;
|
||||
serverId: string | null;
|
||||
isHovered: boolean;
|
||||
projectName?: string;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending" | "success";
|
||||
workspaces?: readonly SidebarWorkspaceEntry[];
|
||||
createButton: SidebarProjectCreateButtonConfig | null;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
export function SidebarProjectTrailingActions({
|
||||
projectKey,
|
||||
serverId,
|
||||
isHovered,
|
||||
projectName,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
workspaces = [],
|
||||
createButton,
|
||||
}: SidebarProjectTrailingActionsProps): ReactElement {
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
const fallbackRemoval = useSidebarProjectRemoval({
|
||||
projectKey,
|
||||
projectName,
|
||||
serverId,
|
||||
workspaces,
|
||||
});
|
||||
const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
|
||||
const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{createButton ? (
|
||||
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
|
||||
) : null}
|
||||
<View
|
||||
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={projectKey}
|
||||
onRemoveProject={resolvedRemoveProject}
|
||||
removeProjectStatus={resolvedRemoveProjectStatus}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCreateButton({
|
||||
actionsVisible,
|
||||
createButton,
|
||||
}: {
|
||||
actionsVisible: boolean;
|
||||
createButton: SidebarProjectCreateButtonConfig;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<NewWorktreeButton
|
||||
accessibilityLabel={createButton.accessibilityLabel}
|
||||
icon={createButton.icon}
|
||||
onPress={createButton.onPress}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={createButton.showShortcutHint ?? false}
|
||||
testID={createButton.testID}
|
||||
tooltipLabel={createButton.tooltipLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useSidebarProjectRemoval({
|
||||
projectKey,
|
||||
projectName,
|
||||
serverId,
|
||||
workspaces,
|
||||
}: {
|
||||
projectKey: string;
|
||||
projectName?: string;
|
||||
serverId: string | null;
|
||||
workspaces: readonly SidebarWorkspaceEntry[];
|
||||
}): {
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending";
|
||||
} {
|
||||
const toast = useToast();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
const displayName = projectName ?? projectKey;
|
||||
|
||||
const handleRemoveProject = useCallback(() => {
|
||||
if (isRemovingProject || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Remove project?",
|
||||
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
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) {
|
||||
toast.error("Failed to remove some workspaces");
|
||||
}
|
||||
setIsRemovingProject(false);
|
||||
return;
|
||||
});
|
||||
})();
|
||||
}, [displayName, isRemovingProject, serverId, toast, workspaces]);
|
||||
|
||||
return {
|
||||
onRemoveProject: handleRemoveProject,
|
||||
removeProjectStatus: isRemovingProject ? "pending" : "idle",
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
function projectKebabStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectKebabMenu({
|
||||
projectKey,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
projectKey: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorktreeButton({
|
||||
accessibilityLabel,
|
||||
icon,
|
||||
onPress,
|
||||
visible,
|
||||
loading = false,
|
||||
testID,
|
||||
showShortcutHint = false,
|
||||
tooltipLabel,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
loading?: boolean;
|
||||
testID: string;
|
||||
showShortcutHint?: boolean;
|
||||
tooltipLabel: string;
|
||||
}) {
|
||||
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.projectIconActionButton,
|
||||
!visible && styles.projectIconActionButtonHidden,
|
||||
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
|
||||
],
|
||||
[visible, loading],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
|
||||
const renderIcon = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (loading) {
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "plus") {
|
||||
return <ThemedPlus size={15} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
|
||||
},
|
||||
[icon, loading],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{renderIcon}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectKebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {},
|
||||
}));
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import {
|
||||
SidebarProjectsDraggableList,
|
||||
type SidebarProjectDragInfo,
|
||||
} from "./sidebar-projects-draggable-list";
|
||||
|
||||
interface CapturedDraggableProps {
|
||||
data: SidebarProjectEntry[];
|
||||
onDragEnd: (next: SidebarProjectEntry[]) => void;
|
||||
}
|
||||
|
||||
const captured: { props: CapturedDraggableProps | null } = { props: null };
|
||||
|
||||
vi.mock("@/components/draggable-list", () => ({
|
||||
DraggableList: (props: CapturedDraggableProps) => {
|
||||
captured.props = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function makeProject(key: string): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: key,
|
||||
projectName: key,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${key}`,
|
||||
workspaces: [],
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTS_ABC: readonly SidebarProjectEntry[] = [
|
||||
makeProject("a"),
|
||||
makeProject("b"),
|
||||
makeProject("c"),
|
||||
];
|
||||
|
||||
const PROJECTS_AB: readonly SidebarProjectEntry[] = [makeProject("a"), makeProject("b")];
|
||||
|
||||
function renderEmpty(): React.ReactElement {
|
||||
return React.createElement(React.Fragment);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
captured.props = null;
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
const noopRender = (_info: SidebarProjectDragInfo): React.ReactElement => renderEmpty();
|
||||
|
||||
describe("SidebarProjectsDraggableList", () => {
|
||||
it("persists the reordered project keys to the order store on drag end", () => {
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_ABC}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props).not.toBeNull();
|
||||
const reordered = [PROJECTS_ABC[1], PROJECTS_ABC[0], PROJECTS_ABC[2]];
|
||||
captured.props?.onDragEnd(reordered);
|
||||
|
||||
expect(useSidebarOrderStore.getState().getProjectOrder("server-1")).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("does not persist when the reordered visible keys match the current order", () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder("server-1", ["a", "b"]);
|
||||
const setSpy = vi.spyOn(useSidebarOrderStore.getState(), "setProjectOrder");
|
||||
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([...PROJECTS_AB]);
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when serverId is null", () => {
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId={null}
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useCallback, type MutableRefObject, type ReactElement } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
|
||||
export interface SidebarProjectDragInfo {
|
||||
project: SidebarProjectEntry;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
interface SidebarProjectsDraggableListProps {
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
serverId: string | null;
|
||||
renderProject: (info: SidebarProjectDragInfo) => ReactElement;
|
||||
/** Native: use the nestable variant when wrapped in a NestableScrollContainer. */
|
||||
nestable?: boolean;
|
||||
/** When false, the outer scroll container owns scrolling. */
|
||||
scrollEnabled?: boolean;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
/** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
export function SidebarProjectsDraggableList({
|
||||
projects,
|
||||
serverId,
|
||||
renderProject,
|
||||
nestable = false,
|
||||
scrollEnabled = false,
|
||||
containerStyle,
|
||||
testID,
|
||||
simultaneousGestureRef,
|
||||
}: SidebarProjectsDraggableListProps): ReactElement {
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
|
||||
const currentProjectOrder = getProjectOrder(serverId);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentProjectOrder,
|
||||
reorderedVisibleKeys: reorderedProjectKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setProjectOrder(
|
||||
serverId,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentProjectOrder,
|
||||
reorderedVisibleKeys: reorderedProjectKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getProjectOrder, serverId, setProjectOrder],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableList
|
||||
testID={testID}
|
||||
data={projects as SidebarProjectEntry[]}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
scrollEnabled={scrollEnabled}
|
||||
useDragHandle
|
||||
nestable={nestable}
|
||||
simultaneousGestureRef={simultaneousGestureRef}
|
||||
containerStyle={containerStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface SidebarShellHeaderProps {
|
||||
children: ReactNode;
|
||||
topSpacerStyle?: StyleProp<ViewStyle>;
|
||||
toggleSlot?: ReactNode;
|
||||
withTitlebarDragRegion?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarShellHeader({
|
||||
children,
|
||||
topSpacerStyle,
|
||||
toggleSlot,
|
||||
withTitlebarDragRegion = false,
|
||||
}: SidebarShellHeaderProps) {
|
||||
const content = (
|
||||
<Fragment>
|
||||
{withTitlebarDragRegion ? <TitlebarDragRegion /> : null}
|
||||
{topSpacerStyle ? <View style={topSpacerStyle} /> : null}
|
||||
{children}
|
||||
{toggleSlot}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (withTitlebarDragRegion || topSpacerStyle) {
|
||||
return <View style={styles.headerDragArea}>{content}</View>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
headerDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { SidebarAgentListSkeleton } from "../sidebar-agent-list-skeleton";
|
||||
import { SidebarWorkspaceList } from "../sidebar-workspace-list";
|
||||
|
||||
interface SidebarWorkspacesViewProps {
|
||||
serverId: string | null;
|
||||
projects: SidebarProjectEntry[];
|
||||
isInitialLoad: boolean;
|
||||
isRefreshing: boolean;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
onRefresh: () => void;
|
||||
onAddProject: () => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspacesView({
|
||||
serverId,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRefreshing,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleProjectCollapsed,
|
||||
onRefresh,
|
||||
onAddProject,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspacesViewProps): ReactElement {
|
||||
if (isInitialLoad) {
|
||||
return <SidebarAgentListSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceList
|
||||
serverId={serverId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onAddProject={onAddProject}
|
||||
parentGestureRef={parentGestureRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useContextMenu } from "@/components/ui/context-menu";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
|
||||
/**
|
||||
* Shared mobile drag affordance: long-press arms a draggable-flatlist drag with haptics,
|
||||
* arbitrating against vertical scroll, horizontal swipe, and an optional context-menu open.
|
||||
*
|
||||
* Web does not exercise this — `dragHandleProps` from `DraggableList` covers web DnD.
|
||||
*/
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const didLongPressRef = useRef(false);
|
||||
const dragArmedRef = useRef(false);
|
||||
const dragActivatedRef = useRef(false);
|
||||
const didStartDragRef = useRef(false);
|
||||
const scrollIntentRef = useRef(false);
|
||||
const menuOpenedRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dragArmTimerRef.current) {
|
||||
clearTimeout(dragArmTimerRef.current);
|
||||
dragArmTimerRef.current = null;
|
||||
}
|
||||
if (contextMenuTimerRef.current) {
|
||||
clearTimeout(contextMenuTimerRef.current);
|
||||
contextMenuTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!input.menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
input.menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
input.menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [input.menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
const CONTEXT_MENU_DELAY_MS = 450;
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||
|
||||
dragArmTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
dragArmedRef.current = true;
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextMenuTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return;
|
||||
}
|
||||
didStartDragRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
scrollIntentRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false;
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
didStartDragRef.current = false;
|
||||
scrollIntentRef.current = false;
|
||||
menuOpenedRef.current = false;
|
||||
touchStartRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
touchCurrentRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
armTimers();
|
||||
},
|
||||
[armTimers],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = { x, y };
|
||||
touchCurrentRef.current = current;
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
});
|
||||
|
||||
if (decision === "vertical_scroll") {
|
||||
handleScrollIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||
handleSwipeIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "start_drag") {
|
||||
handleDragIntent({ dx, dy, distance });
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||
);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers();
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
touchStartRef.current = null;
|
||||
touchCurrentRef.current = null;
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handleTouchMove,
|
||||
handlePressOut,
|
||||
};
|
||||
}
|
||||
@@ -14,17 +14,17 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
||||
import { resolveToolCallIcon } 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,7 +139,9 @@ interface ToolCallSheetContentProps {
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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,7 +145,6 @@ 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 "@/git/use-pr-status-query";
|
||||
import type { PrHint } from "@/hooks/use-checkout-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,7 +17,6 @@ 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";
|
||||
|
||||
@@ -152,6 +151,7 @@ export function WorkspaceSetupDialog() {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
||||
initialCwd: sourceDirectory,
|
||||
composer: buildChatDraftComposerArgs({
|
||||
serverId,
|
||||
isConnected,
|
||||
@@ -191,14 +191,6 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
if (target.kind === "agent") {
|
||||
navigateToAgent({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
@@ -401,7 +393,7 @@ export function WorkspaceSetupDialog() {
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
cwd={chatDraft.cwd}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
|
||||
@@ -27,7 +27,6 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
title: "Agent",
|
||||
cwd: "/tmp",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkoutDiffQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
it("invalidates every query for a checkout without touching other checkouts", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
{ items: [] },
|
||||
);
|
||||
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
)?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutDiff", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function checkoutQueryPredicate(
|
||||
queryKind: CheckoutQueryKey[0],
|
||||
identity: CheckoutQueryIdentity,
|
||||
): (query: Query) => boolean {
|
||||
return (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
isCheckoutQueryKey(key) &&
|
||||
key[0] === queryKind &&
|
||||
key[1] === identity.serverId &&
|
||||
key[2] === identity.cwd
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
|
||||
return (
|
||||
key.length >= 3 &&
|
||||
typeof key[0] === "string" &&
|
||||
typeof key[1] === "string" &&
|
||||
typeof key[2] === "string"
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./diff-order";
|
||||
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
function createFile(path: string, additions = 0) {
|
||||
return {
|
||||
@@ -150,7 +150,6 @@ function makeActiveAgent(): Agent {
|
||||
model: null,
|
||||
labels: {},
|
||||
archivedAt: null,
|
||||
parentAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,54 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface DraftStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StoredDraft {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface AgentInputDraftCore {
|
||||
load(initialCwd?: string): Promise<StoredDraft | null>;
|
||||
save(draft: StoredDraft): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAgentInputDraftCore(input: {
|
||||
storage: DraftStorage;
|
||||
storageKey: string;
|
||||
}): AgentInputDraftCore {
|
||||
return {
|
||||
async load(initialCwd?: string) {
|
||||
const json = await input.storage.getItem(input.storageKey);
|
||||
if (!json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
return {
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
attachments: Array.isArray(parsed.attachments)
|
||||
? (parsed.attachments as UserComposerAttachment[])
|
||||
: [],
|
||||
cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async save(draft) {
|
||||
await input.storage.setItem(input.storageKey, JSON.stringify(draft));
|
||||
},
|
||||
async clear() {
|
||||
await input.storage.removeItem(input.storageKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
@@ -104,8 +152,11 @@ export function buildDraftStatusControls(input: {
|
||||
export function hasDraftContent(input: {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
cwd: string;
|
||||
}): boolean {
|
||||
return input.text.trim().length > 0 || input.attachments.length > 0;
|
||||
return (
|
||||
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function areAttachmentsEqual(input: {
|
||||
|
||||
@@ -10,10 +10,6 @@ const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
@@ -254,7 +250,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:legacy" });
|
||||
latest = useAgentInputDraft({ draftKey: "draft:legacy", initialCwd: "/repo" });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -275,13 +271,15 @@ describe("useAgentInputDraft live contract", () => {
|
||||
|
||||
expect(getLatest().text).toBe("legacy text");
|
||||
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
||||
expect(getLatest().cwd).toBe("/repo");
|
||||
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
|
||||
text: "legacy text",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates drafts saved by old builds with cwd", async () => {
|
||||
it("round-trips persisted attachments and cwd unchanged", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const githubIssue: ComposerAttachment = {
|
||||
kind: "github_issue",
|
||||
@@ -314,13 +312,13 @@ describe("useAgentInputDraft live contract", () => {
|
||||
lifecycle: "active",
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
} as unknown as DraftRecordForTest,
|
||||
},
|
||||
},
|
||||
createModalDraft: null,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:new-shape" });
|
||||
latest = useAgentInputDraft({ draftKey: "draft:new-shape", initialCwd: "/initial" });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -341,6 +339,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
|
||||
expect(getLatest().text).toBe("new text");
|
||||
expect(getLatest().attachments).toEqual([githubIssue]);
|
||||
expect(getLatest().cwd).toBe("/persisted");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -353,6 +352,112 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds cwd from initialCwd when persisted cwd is missing", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
useDraftStore.setState({
|
||||
drafts: {
|
||||
"draft:seed-cwd": {
|
||||
input: {
|
||||
text: "seed",
|
||||
attachments: [],
|
||||
},
|
||||
lifecycle: "active",
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
} as unknown as DraftRecordForTest,
|
||||
},
|
||||
createModalDraft: null,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:seed-cwd", initialCwd: "/initial" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(getLatest().cwd).toBe("/initial");
|
||||
expect(useDraftStore.getState().drafts["draft:seed-cwd"]?.input).toEqual({
|
||||
text: "seed",
|
||||
attachments: [],
|
||||
cwd: "/initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds cwd as empty string when persisted cwd and initialCwd are missing", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
useDraftStore.setState({
|
||||
drafts: {
|
||||
"draft:empty-cwd": {
|
||||
input: {
|
||||
text: "seed",
|
||||
attachments: [],
|
||||
},
|
||||
lifecycle: "active",
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
} as unknown as DraftRecordForTest,
|
||||
},
|
||||
createModalDraft: null,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:empty-cwd" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(getLatest().cwd).toBe("");
|
||||
expect(useDraftStore.getState().drafts["draft:empty-cwd"]?.input).toEqual({
|
||||
text: "seed",
|
||||
attachments: [],
|
||||
cwd: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates and persists attachments through setAttachments", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
@@ -371,7 +476,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:attachments" });
|
||||
latest = useAgentInputDraft({ draftKey: "draft:attachments", initialCwd: "/repo" });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -399,10 +504,11 @@ describe("useAgentInputDraft live contract", () => {
|
||||
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
|
||||
text: "with attachment",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("clear resets text and attachments", async () => {
|
||||
it("clear resets text, attachments, and cwd", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
id: "clear-image",
|
||||
@@ -420,7 +526,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:clear" });
|
||||
latest = useAgentInputDraft({ draftKey: "draft:clear", initialCwd: "/repo" });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -442,6 +548,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
getLatest().setCwd("/changed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -450,9 +557,11 @@ describe("useAgentInputDraft live contract", () => {
|
||||
|
||||
expect(getLatest().text).toBe("");
|
||||
expect(getLatest().attachments).toEqual([]);
|
||||
expect(getLatest().cwd).toBe("");
|
||||
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
|
||||
text: "",
|
||||
attachments: [],
|
||||
cwd: "",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -506,7 +615,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
expect(getLatest().attachments).toEqual([]);
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "sent",
|
||||
input: { text: "", attachments: [] },
|
||||
input: { text: "", attachments: [], cwd: "" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -519,7 +628,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "abandoned",
|
||||
input: { text: "", attachments: [] },
|
||||
input: { text: "", attachments: [], cwd: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDraftComposerCommandConfig,
|
||||
createAgentInputDraftCore,
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "./use-agent-input-draft-core";
|
||||
|
||||
function makeStorage() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: async (key: string) => map.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
map.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDraftKey", () => {
|
||||
it("returns a string draft key unchanged", () => {
|
||||
expect(
|
||||
@@ -115,3 +129,60 @@ describe("buildDraftComposerCommandConfig", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentInputDraftCore", () => {
|
||||
it("load returns null when nothing has been saved", async () => {
|
||||
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||
expect(await core.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("load returns the draft that was saved", async () => {
|
||||
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||
await core.save({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||
expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("clear removes the persisted draft", async () => {
|
||||
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||
await core.save({ text: "hello", attachments: [], cwd: "/repo" });
|
||||
await core.clear();
|
||||
expect(await core.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => {
|
||||
const storage = makeStorage();
|
||||
await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] }));
|
||||
|
||||
const core = createAgentInputDraftCore({ storage, storageKey: "test" });
|
||||
const draft = await core.load("/initial");
|
||||
expect(draft?.cwd).toBe("/initial");
|
||||
});
|
||||
|
||||
it("round-trips attachments unchanged", async () => {
|
||||
const attachment = {
|
||||
kind: "github_issue" as const,
|
||||
item: {
|
||||
kind: "issue" as const,
|
||||
number: 42,
|
||||
title: "Unify attachments",
|
||||
url: "https://github.com/example/repo/issues/42",
|
||||
state: "open" as const,
|
||||
body: "body",
|
||||
labels: ["composer"],
|
||||
},
|
||||
};
|
||||
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||
await core.save({ text: "", attachments: [attachment], cwd: "/repo" });
|
||||
const draft = await core.load();
|
||||
expect(draft?.attachments).toEqual([attachment]);
|
||||
});
|
||||
|
||||
it("isolated keys do not share state", async () => {
|
||||
const storage = makeStorage();
|
||||
const a = createAgentInputDraftCore({ storage, storageKey: "key-a" });
|
||||
const b = createAgentInputDraftCore({ storage, storageKey: "key-b" });
|
||||
await a.save({ text: "from a", attachments: [], cwd: "" });
|
||||
expect(await b.load()).toBeNull();
|
||||
expect(await a.load()).toMatchObject({ text: "from a" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ interface AgentInputDraftComposerOptions {
|
||||
|
||||
interface UseAgentInputDraftInput {
|
||||
draftKey: DraftKeyInput;
|
||||
initialCwd?: string;
|
||||
composer?: AgentInputDraftComposerOptions;
|
||||
}
|
||||
|
||||
@@ -51,6 +52,8 @@ interface AgentInputDraft {
|
||||
setText: (text: string) => void;
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
cwd: string;
|
||||
setCwd: (cwd: string) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
composerState: DraftComposerState | null;
|
||||
@@ -75,6 +78,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const draftGenerationRef = useRef(0);
|
||||
const hydratedGenerationRef = useRef(0);
|
||||
@@ -99,6 +103,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setCwd("");
|
||||
setIsHydrated(true);
|
||||
},
|
||||
[draftKey],
|
||||
@@ -112,6 +117,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setCwd(input.initialCwd ?? "");
|
||||
setIsHydrated(false);
|
||||
|
||||
let cancelled = false;
|
||||
@@ -119,6 +125,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
void (async () => {
|
||||
const draft = await store.hydrateDraftInput({
|
||||
draftKey,
|
||||
initialCwd: input.initialCwd,
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -130,6 +137,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
if (draft) {
|
||||
setText(draft.text);
|
||||
setAttachmentsState(draft.attachments);
|
||||
setCwd(draft.cwd);
|
||||
}
|
||||
|
||||
hydratedGenerationRef.current = generation;
|
||||
@@ -139,7 +147,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draftKey]);
|
||||
}, [draftKey, input.initialCwd]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentGeneration = draftGenerationRef.current;
|
||||
@@ -163,6 +171,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const isSameDraft =
|
||||
existing !== undefined &&
|
||||
existing.text === text &&
|
||||
existing.cwd === cwd &&
|
||||
areAttachmentsEqual({
|
||||
left: existing.attachments,
|
||||
right: attachments,
|
||||
@@ -171,7 +180,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasDraftContent({ text, attachments })) {
|
||||
if (!hasDraftContent({ text, attachments, cwd })) {
|
||||
if (existing) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
}
|
||||
@@ -183,9 +192,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
draft: {
|
||||
text,
|
||||
attachments,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
}, [attachments, draftKey, text]);
|
||||
}, [attachments, cwd, draftKey, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
@@ -291,6 +301,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
cwd,
|
||||
setCwd,
|
||||
clear,
|
||||
isHydrated,
|
||||
composerState,
|
||||
|
||||
@@ -42,7 +42,6 @@ function createAgent(id: string): Agent {
|
||||
title: "Agent",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useAggregatedAgentIds, useAggregatedAgents } from "./use-aggregated-agents";
|
||||
|
||||
const { mockRuntimeStore } = vi.hoisted(() => ({
|
||||
mockRuntimeStore: {
|
||||
subscribeAll: vi.fn(() => () => undefined),
|
||||
getVersion: vi.fn(() => 1),
|
||||
getSnapshot: vi.fn(() => ({ agentDirectoryStatus: "idle" })),
|
||||
refreshAllAgentDirectories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => mockRuntimeStore,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent>): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function seedAgents(agents: Agent[]) {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents("server-1", new Map(agents.map((agent) => [agent.id, agent])));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockRuntimeStore.subscribeAll.mockClear();
|
||||
mockRuntimeStore.getVersion.mockClear();
|
||||
mockRuntimeStore.getSnapshot.mockClear();
|
||||
mockRuntimeStore.refreshAllAgentDirectories.mockClear();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("useAggregatedAgents", () => {
|
||||
it("sorts by createdAt descending by default and falls back to id for ties", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "newest",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgents());
|
||||
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["newest", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("can preserve the legacy running-then-activity sort", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "idle-newer",
|
||||
status: "idle",
|
||||
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "running-older",
|
||||
status: "running",
|
||||
lastActivityAt: new Date("2026-05-08T09:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "running-newer",
|
||||
status: "running",
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgents({ sort: "running-then-activity" }));
|
||||
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual([
|
||||
"running-newer",
|
||||
"running-older",
|
||||
"idle-newer",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAggregatedAgentIds", () => {
|
||||
it("keeps the same id array reference when membership and order are unchanged", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
title: "Alpha",
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
title: "Beta",
|
||||
status: "running",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgentIds());
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
[
|
||||
"alpha",
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
title: "Renamed Alpha",
|
||||
status: "running",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"beta",
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
title: "Renamed Beta",
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T12:30:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
expect(result.current).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("changes the id array reference when membership changes", () => {
|
||||
seedAgents([makeAgent({ id: "alpha" })]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgentIds());
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
["alpha", makeAgent({ id: "alpha" })],
|
||||
["beta", makeAgent({ id: "beta", createdAt: new Date("2026-05-08T11:00:00.000Z") })],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe(before);
|
||||
expect(result.current).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("keeps the same id array reference when a local predicate still yields the same ids", () => {
|
||||
seedAgents([
|
||||
makeAgent({ id: "mapped", cwd: "/tmp/project" }),
|
||||
makeAgent({
|
||||
id: "unmapped",
|
||||
cwd: "/tmp/missing",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAggregatedAgentIds({
|
||||
filter: (agent) => agent.cwd === "/tmp/project" && !agent.archivedAt,
|
||||
}),
|
||||
);
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
["mapped", makeAgent({ id: "mapped", cwd: "/tmp/project", title: "Renamed" })],
|
||||
[
|
||||
"unmapped",
|
||||
makeAgent({
|
||||
id: "unmapped",
|
||||
cwd: "/tmp/missing",
|
||||
title: "Still hidden",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
expect(result.current).toEqual(["mapped"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -18,12 +19,151 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
export type AggregatedAgentSortOption = "createdAt-desc-stable" | "running-then-activity";
|
||||
|
||||
export interface AggregatedAgentIdEntry {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
cwd: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AggregatedAgentSortFields {
|
||||
id: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByCreatedAtDescStable(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime();
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByRunningThenActivity(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
function sortAggregatedAgents(
|
||||
agents: AggregatedAgent[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgent[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
function sortAggregatedAgentIdEntries(
|
||||
agents: AggregatedAgentIdEntry[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgentIdEntry[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function useAggregatedAgentIds(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
filter?: (agent: AggregatedAgentIdEntry) => boolean;
|
||||
}): string[] {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const filter = options?.filter;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
const agents: AggregatedAgentIdEntry[] = [];
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const entry: AggregatedAgentIdEntry = {
|
||||
id: agent.id,
|
||||
serverId,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
if (!filter || filter(entry)) {
|
||||
agents.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortAggregatedAgentIdEntries(agents, sort).map((agent) => agent.id);
|
||||
},
|
||||
shallow,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAggregatedAgentsInitialLoad(): boolean {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const agentCount = useSessionStore((state) => {
|
||||
let count = 0;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!agent.archivedAt) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
void runtimeVersion;
|
||||
const isLoading = daemons.some((daemon) => {
|
||||
const status = runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
|
||||
return status === "initial_loading" || status === "revalidating";
|
||||
});
|
||||
return isLoading && agentCount === 0;
|
||||
}
|
||||
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
}): AggregatedAgentsResult {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
@@ -83,20 +223,7 @@ export function useAggregatedAgents(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by: running agents first, then by most recent activity
|
||||
allAgents.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
sortAggregatedAgents(allAgents, sort);
|
||||
|
||||
// Check if we have any cached data
|
||||
const hasAnyData = allAgents.length > 0;
|
||||
@@ -116,7 +243,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __private__ } from "./use-all-agents-list";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
refreshAgentDirectory: vi.fn(),
|
||||
}),
|
||||
useHostRuntimeConnectionStatus: () => "online",
|
||||
useHostRuntimeIsDirectoryLoading: () => false,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
@@ -36,7 +45,6 @@ const AGENT_DEFAULTS: Agent = {
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import {
|
||||
__private__,
|
||||
applyArchivedAgentCloseResults,
|
||||
usePendingArchiveAgentIds,
|
||||
} from "./use-archive-agent";
|
||||
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
@@ -40,7 +31,6 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
title: "Agent 1",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
archivedAt: null,
|
||||
...overrides,
|
||||
@@ -101,48 +91,6 @@ describe("useArchiveAgent", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("selects pending archive ids for a single server", () => {
|
||||
const pendingIds = __private__.selectPendingArchiveAgentIds(
|
||||
{
|
||||
"server-a:agent-1": true,
|
||||
"server-a:agent-2": true,
|
||||
"server-b:agent-3": true,
|
||||
},
|
||||
"server-a",
|
||||
);
|
||||
|
||||
expect(Array.from(pendingIds)).toEqual(["agent-1", "agent-2"]);
|
||||
});
|
||||
|
||||
it("subscribes to pending archive ids through react-query", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => usePendingArchiveAgentIds("server-a"), { wrapper });
|
||||
|
||||
expect(Array.from(result.current)).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
__private__.setAgentArchiving({
|
||||
queryClient,
|
||||
serverId: "server-a",
|
||||
agentId: "agent-1",
|
||||
isArchiving: true,
|
||||
});
|
||||
__private__.setAgentArchiving({
|
||||
queryClient,
|
||||
serverId: "server-b",
|
||||
agentId: "agent-2",
|
||||
isArchiving: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Array.from(result.current)).toEqual(["agent-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes an archived agent from cached list payloads", () => {
|
||||
const payload = {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user