mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a0cb4a99 | ||
|
|
342e92d0c7 | ||
|
|
0170eba233 | ||
|
|
b6103a59da | ||
|
|
24526a3b23 | ||
|
|
6d1aa1f415 | ||
|
|
4b973ae9ec | ||
|
|
e02e8426d4 | ||
|
|
e4188f5222 | ||
|
|
8262fb42af | ||
|
|
3176f844e7 | ||
|
|
8234ecb7ba | ||
|
|
adb9a57cfc | ||
|
|
93c14cb8ad | ||
|
|
a00152290f | ||
|
|
8e4cbf8ca6 | ||
|
|
0737c5c973 | ||
|
|
c4f9874e1f | ||
|
|
990bca71b7 | ||
|
|
f431ebee6d | ||
|
|
93189148f5 | ||
|
|
3baba543a4 | ||
|
|
f20393dbb7 | ||
|
|
3ac182cffc | ||
|
|
5cede0a7bb | ||
|
|
53c14d9855 | ||
|
|
0ea41378a4 | ||
|
|
00759e7994 | ||
|
|
e3eb333ddc | ||
|
|
a025f17a73 | ||
|
|
a4cb7431d8 | ||
|
|
5696cdb455 | ||
|
|
5a56835db3 | ||
|
|
698d549983 | ||
|
|
fa1b3e88f0 | ||
|
|
5d8dc800fc | ||
|
|
dbfd42da46 | ||
|
|
2894917a1c | ||
|
|
9c2d47ab34 | ||
|
|
d787aefa4c | ||
|
|
8307a0ca6f | ||
|
|
7aa49b2905 | ||
|
|
d594bce153 | ||
|
|
a630986d06 | ||
|
|
724a499413 | ||
|
|
8aa1530be1 | ||
|
|
1908ab8765 | ||
|
|
5ad6cff039 | ||
|
|
8a463c55c8 | ||
|
|
aecb300073 | ||
|
|
4911b627f2 | ||
|
|
a5b82d2a3b | ||
|
|
09bf981f13 | ||
|
|
3cf92ad6c5 | ||
|
|
153fa42a95 | ||
|
|
6d205f8853 | ||
|
|
5468089ac0 | ||
|
|
0ab41fbd9a | ||
|
|
ed6caa11c0 | ||
|
|
74c8942a28 | ||
|
|
3eb1ba7d73 | ||
|
|
1d2c8b1648 | ||
|
|
88914ccba6 | ||
|
|
f79101a1f0 | ||
|
|
b492c70825 | ||
|
|
3ace6c5602 | ||
|
|
be7979fb34 | ||
|
|
58e7fd0e3e | ||
|
|
81407c5ffc | ||
|
|
9c5f6007be | ||
|
|
fab721205d | ||
|
|
e74c7f9554 | ||
|
|
4231dbbba4 | ||
|
|
7e792e899a | ||
|
|
1d38aacf5c | ||
|
|
a3071b46a4 | ||
|
|
483790d8c9 | ||
|
|
bde0efc9b0 | ||
|
|
6f16900b8a | ||
|
|
c31ab074e9 | ||
|
|
bf69ebeddf | ||
|
|
22e014aaff | ||
|
|
91b05b4111 | ||
|
|
6e56ee9e32 | ||
|
|
48d4e3e408 | ||
|
|
30a8bffc24 | ||
|
|
d271597ee8 | ||
|
|
893dd6d1fe | ||
|
|
53faba64ca | ||
|
|
e5658654c0 | ||
|
|
f0730d5eac | ||
|
|
e312e0d6f3 | ||
|
|
9806a893ed | ||
|
|
5dd2afaac9 | ||
|
|
2252046f56 | ||
|
|
423956c6a0 | ||
|
|
19289286a6 | ||
|
|
e40ad0c00e | ||
|
|
1e68283565 | ||
|
|
8a2e5c786e | ||
|
|
178708a44e | ||
|
|
05eec04b10 | ||
|
|
b7ea5b4c9d | ||
|
|
655d05add8 | ||
|
|
f137705f1e | ||
|
|
6b35c0a640 | ||
|
|
a91dfb63c6 | ||
|
|
132e572d6e | ||
|
|
e966f70322 | ||
|
|
62780448dc | ||
|
|
5755ca77f8 | ||
|
|
ba724956df | ||
|
|
7fbbb44ad6 | ||
|
|
4c97ff8fa0 | ||
|
|
c0d2f20056 | ||
|
|
2a3cfc684b | ||
|
|
68c893f643 | ||
|
|
d43d30eeeb | ||
|
|
79dcbdc1c1 | ||
|
|
94bccf19ba | ||
|
|
83f205bd3d | ||
|
|
846c9b9da3 | ||
|
|
84f1dfc8cb | ||
|
|
33892f0698 | ||
|
|
0962529d48 | ||
|
|
008e4e846f | ||
|
|
789a559b31 | ||
|
|
f5f1ae7fa9 | ||
|
|
8e0ebfcaaa | ||
|
|
b151dfcfd5 | ||
|
|
de7bf2fb01 | ||
|
|
5f11f602fb | ||
|
|
369c5a4498 | ||
|
|
c46ff2e045 | ||
|
|
af10e64f82 | ||
|
|
db44a3e0e1 | ||
|
|
25c4cee01e | ||
|
|
3ec4e2c536 | ||
|
|
7dd9cbc506 | ||
|
|
6dc22483c5 |
78
.github/workflows/ci.yml
vendored
78
.github/workflows/ci.yml
vendored
@@ -69,18 +69,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -107,11 +107,8 @@ jobs:
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
@@ -137,14 +134,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
@@ -165,12 +156,37 @@ jobs:
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -187,14 +203,11 @@ jobs:
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
@@ -228,7 +241,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
@@ -254,9 +267,6 @@ jobs:
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
|
||||
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -28,8 +28,8 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/app
|
||||
|
||||
1
.github/workflows/deploy-website.yml
vendored
1
.github/workflows/deploy-website.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
- "public-docs/**"
|
||||
- "packages/website/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
24
.github/workflows/desktop-release.yml
vendored
24
.github/workflows/desktop-release.yml
vendored
@@ -183,6 +183,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-macos-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -273,6 +281,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-linux
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -360,6 +376,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
2
.github/workflows/nix.yml
vendored
2
.github/workflows/nix.yml
vendored
@@ -10,6 +10,8 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
112
CHANGELOG.md
112
CHANGELOG.md
@@ -1,5 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.85 - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Opus 4.8 in the Claude model picker**, with a 1M-context variant
|
||||
|
||||
### Improved
|
||||
|
||||
- Archiving a worktree now keeps its agents under the archived list instead of removing them
|
||||
- Archiving an agent cleans up any schedules targeting it
|
||||
|
||||
## 0.1.84 - 2026-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-accept tool calls for OpenCode agents**
|
||||
|
||||
### Improved
|
||||
|
||||
- Copy an OpenCode resume command to continue the session outside Paseo
|
||||
- Model selector lists every enabled provider, with a Retry button when one fails to load
|
||||
- Provider settings are easier to search and manage
|
||||
- Other agents connecting to Paseo via MCP see the same providers, models, and modes as the app ([#1198](https://github.com/getpaseo/paseo/pull/1198))
|
||||
- OpenCode Edit tool calls render as inline diffs
|
||||
- Typing a slash command shows the best match first
|
||||
- Daemon starts faster on workspaces with many git folders
|
||||
- Markdown lists have tighter spacing
|
||||
- Less jank when streaming agent responses
|
||||
- User message footer controls align with the rest of the chat
|
||||
- Agent mode controls use a cleaner monochrome treatment
|
||||
- Compact layouts move the context ring to the footer right edge
|
||||
|
||||
### Fixed
|
||||
|
||||
- Allow selecting text in the chat on mobile ([#1153](https://github.com/getpaseo/paseo/pull/1153) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- Submitting a Pi question no longer looks like a second prompt opened ([#1188](https://github.com/getpaseo/paseo/pull/1188) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Daemon memory leak from unbounded workspace git caches ([#1200](https://github.com/getpaseo/paseo/pull/1200))
|
||||
- Provider diagnostics include the command override binary path ([#1191](https://github.com/getpaseo/paseo/pull/1191))
|
||||
- OpenCode MCP servers connect correctly when the daemon binds to wildcard addresses
|
||||
- Tool calls from MCP servers that return non-spec output no longer fail validation
|
||||
|
||||
## 0.1.83 - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating an agent via MCP now waits for it to actually start, so failures surface as a clear create error
|
||||
- Scheduling an agent via MCP no longer rejects blank cadence placeholders
|
||||
- Draft messages show the agent mode chip again on models without thinking options
|
||||
|
||||
## 0.1.82 - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Rewind chat or files from any user message** ([#1154](https://github.com/getpaseo/paseo/pull/1154))
|
||||
- **See the cumulative cost of an agent session** ([#1163](https://github.com/getpaseo/paseo/pull/1163))
|
||||
- **Drop files onto the terminal to insert their paths** ([#1173](https://github.com/getpaseo/paseo/pull/1173))
|
||||
- **Tap a file path in the terminal to open it in the workspace preview** ([#1174](https://github.com/getpaseo/paseo/pull/1174))
|
||||
- **Approve OpenCode permissions for the whole session** ([#1168](https://github.com/getpaseo/paseo/pull/1168))
|
||||
- **Workspace scripts now appear on the mobile header** ([#1093](https://github.com/getpaseo/paseo/pull/1093) by [@ayhanmalkoc](https://github.com/ayhanmalkoc))
|
||||
- Devin CLI in the ACP provider catalog (by [@Alcimerio](https://github.com/Alcimerio))
|
||||
- OpenCode agents show their mode colors
|
||||
|
||||
### Improved
|
||||
|
||||
- Mobile terminal keyboard hides when you open a sidebar
|
||||
- Tool activity for read, write, and OpenCode tools renders more consistently ([#1171](https://github.com/getpaseo/paseo/pull/1171))
|
||||
- Compact workspace header actions are tidier
|
||||
- Settings latency readouts are easier to scan ([#1170](https://github.com/getpaseo/paseo/pull/1170))
|
||||
- Pull request merge is available as soon as GitHub reports the PR is ready ([#1172](https://github.com/getpaseo/paseo/pull/1172))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile slash command autocomplete no longer flickers or mis-layers
|
||||
- Interrupting an OpenCode agent returns it to idle instead of showing an error ([#1169](https://github.com/getpaseo/paseo/pull/1169))
|
||||
- Provider model selection per workspace is honored ([#1167](https://github.com/getpaseo/paseo/pull/1167))
|
||||
- Draft composer keeps the permission mode you selected ([#1175](https://github.com/getpaseo/paseo/pull/1175))
|
||||
- Terminal color queries no longer return malformed replies
|
||||
- File links in chat no longer crash when a message contains a bare '%' (by [@Elliotwu-7](https://github.com/Elliotwu-7))
|
||||
|
||||
## 0.1.81 - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Paseo can now be installed as a web app from supported browsers** ([#1144](https://github.com/getpaseo/paseo/pull/1144))
|
||||
- **Pi extension dialogs now appear as Paseo permission prompts** ([#1134](https://github.com/getpaseo/paseo/pull/1134) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Added community links and a home button to the sidebar
|
||||
|
||||
### Improved
|
||||
|
||||
- **Mobile terminals load faster and restore existing output more smoothly** ([#1147](https://github.com/getpaseo/paseo/pull/1147))
|
||||
- Copying assistant messages preserves formatting
|
||||
- Agent metadata fallback failures now log each provider attempt for easier debugging
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android: slash command suggestions stay interactive when opened from the composer
|
||||
- macOS: Alt+letter shortcuts work again
|
||||
- Terminal panes no longer flicker during resize
|
||||
- OpenCode MCP servers are injected once instead of being connected twice
|
||||
- Import session no longer shows empty sessions
|
||||
- Worktree archive status no longer reports false unpushed commits ([#1158](https://github.com/getpaseo/paseo/pull/1158))
|
||||
- The `/exit`, `/quit`, and `/q` slash command aliases now show as one row
|
||||
- Shortcut chord badges are readable in light mode
|
||||
- Segmented controls show their track under every segment
|
||||
- Sheet header search text is readable in dark mode
|
||||
|
||||
## 0.1.80 - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Opening dropdown menus no longer crashes on mobile
|
||||
|
||||
## 0.1.79 - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -32,6 +32,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [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 |
|
||||
@@ -70,8 +71,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- **Always run typecheck and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
|
||||
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||
- `npm run build:client` — rebuild protocol and client declarations.
|
||||
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
|
||||
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
@@ -131,8 +134,8 @@ npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
# build the server stack
|
||||
npm run build:server
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
|
||||
@@ -56,7 +56,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
|
||||
|
||||
## 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`):
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
@@ -68,9 +68,20 @@ All paths are under `packages/server/src/`.
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
|
||||
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
|
||||
agent timeline types, provider config schemas, and other values shared by daemon
|
||||
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
|
||||
it does not depend on the server.
|
||||
|
||||
### `packages/client` — Daemon client library and SDK facade
|
||||
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
@@ -79,6 +90,7 @@ 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
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
@@ -125,7 +137,7 @@ 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 WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
|
||||
- [Custom binary for a provider](#custom-binary-for-a-provider)
|
||||
- [Disabling a provider](#disabling-a-provider)
|
||||
@@ -59,29 +60,7 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
|
||||
|
||||
---
|
||||
|
||||
@@ -206,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
|
||||
---
|
||||
|
||||
## Codex with a custom OpenAI-compatible endpoint
|
||||
|
||||
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
||||
|
||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
|
||||
|
||||
### Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"description": "Codex via custom OpenAI-compatible endpoint",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What Paseo wires up
|
||||
|
||||
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
||||
|
||||
```toml
|
||||
model_provider = "my-codex"
|
||||
|
||||
[model_providers.my-codex]
|
||||
name = "My Codex"
|
||||
base_url = "https://custom-relay.example.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
requires_openai_auth = false
|
||||
```
|
||||
|
||||
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
|
||||
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
|
||||
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
|
||||
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
|
||||
|
||||
### Notes
|
||||
|
||||
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
|
||||
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
|
||||
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
|
||||
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
|
||||
|
||||
---
|
||||
|
||||
## Multiple profiles for the same provider
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
@@ -171,6 +171,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
@@ -109,23 +109,28 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
## Built workspace packages
|
||||
|
||||
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.
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
|
||||
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
|
||||
|
||||
Use the named root build targets instead of remembering workspace dependency chains:
|
||||
|
||||
```bash
|
||||
npm run build:daemon
|
||||
npm run build:client # protocol -> client
|
||||
npm run build:server-deps # highlight -> relay -> protocol -> client
|
||||
npm run build:server # server-deps -> server -> cli
|
||||
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
|
||||
```
|
||||
|
||||
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.
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- 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).
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## CLI reference
|
||||
|
||||
@@ -187,6 +192,20 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
|
||||
|
||||
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.
|
||||
|
||||
## App web deploys
|
||||
|
||||
`packages/app` exports a single-page Expo web app and deploys the `dist/`
|
||||
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
|
||||
|
||||
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
|
||||
from `packages/app/public/index.html`. Keep the install icons in `public/` so
|
||||
Cloudflare serves them from stable root URLs after `expo export`.
|
||||
|
||||
Do not add service-worker caching casually. Paseo is a live control surface for
|
||||
agents, and an aggressive service worker can strand installed users on stale web
|
||||
code. If offline behavior becomes a product requirement, add it deliberately
|
||||
with an update strategy and test the installed-app upgrade path.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
|
||||
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Git Snapshot Startup Reshaping - 2026-05-27
|
||||
|
||||
## What changed
|
||||
|
||||
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
|
||||
|
||||
```text
|
||||
daemon startup / workspace subscription
|
||||
-> WorkspaceGitService.refreshSnapshot(cwd)
|
||||
-> getCheckoutSnapshotFacts(cwd)
|
||||
-> getCheckoutStatus(cwd, { facts })
|
||||
-> getCheckoutShortstat(cwd, { facts })
|
||||
-> getPullRequestStatus(cwd, github, ..., { facts })
|
||||
-> WorkspaceGitRuntimeSnapshot
|
||||
-> session workspace descriptor githubRuntime.pullRequest
|
||||
-> app useSidebarWorkspacesList()
|
||||
-> SidebarWorkspaceEntry.prHint
|
||||
-> Sidebar row badge + hover card checks
|
||||
```
|
||||
|
||||
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
|
||||
|
||||
## Shared Git Facts
|
||||
|
||||
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
|
||||
|
||||
- worktree root: `rev-parse --show-toplevel`
|
||||
- current branch: `rev-parse --abbrev-ref HEAD`
|
||||
- origin remote URL
|
||||
- Paseo worktree ownership and stored base ref
|
||||
- resolved base ref and best comparison base
|
||||
- main repo root
|
||||
- branch remote/merge config
|
||||
- tracked origin branch
|
||||
- pull request lookup target for fork/PR worktrees
|
||||
|
||||
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
|
||||
|
||||
## Current Data Flow
|
||||
|
||||
```text
|
||||
Workspace subscription / fetch_workspaces
|
||||
-> session workspace registry
|
||||
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
|
||||
-> refresh queue/throttle/dedupe per normalized cwd
|
||||
-> refreshGitSnapshot()
|
||||
-> getCheckoutSnapshotFacts()
|
||||
-> getCheckoutStatus({ facts })
|
||||
-> getCheckoutShortstat({ facts })
|
||||
-> refreshGitHubSnapshot()
|
||||
-> getPullRequestStatus({ facts })
|
||||
-> cached WorkspaceGitRuntimeSnapshot
|
||||
-> WorkspaceDescriptorPayload.gitRuntime
|
||||
-> WorkspaceDescriptorPayload.githubRuntime
|
||||
-> app session store
|
||||
-> useSidebarWorkspacesList()
|
||||
-> diffStat from descriptor
|
||||
-> prHint from descriptor.githubRuntime.pullRequest
|
||||
```
|
||||
|
||||
## Startup Benchmark
|
||||
|
||||
Added deterministic real-home benchmark:
|
||||
|
||||
`packages/server/scripts/benchmark-startup-git-real-home.ts`
|
||||
|
||||
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
|
||||
|
||||
The frozen home used for the comparison contained 22 workspaces.
|
||||
|
||||
### Before/After
|
||||
|
||||
| run | code shape | client shape | git commands | failures | elapsed |
|
||||
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
|
||||
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
|
||||
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
|
||||
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
|
||||
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
|
||||
|
||||
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
|
||||
|
||||
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
|
||||
|
||||
### Baseline: before change + legacy PR fanout
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "legacyPrFanout",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 39039,
|
||||
"git": {
|
||||
"total": 529,
|
||||
"failed": 20,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 58 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
|
||||
{ "key": "rev-parse --git-dir", "count": 36 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
|
||||
{ "key": "config --get remote.origin.url", "count": 32 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 14 },
|
||||
{ "key": "status --porcelain", "count": 14 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 2009,
|
||||
"cpuSystemMs": 2428,
|
||||
"rssDeltaMb": -1.5,
|
||||
"heapUsedDeltaMb": 16.9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: after change + snapshot-only sidebar
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31273,
|
||||
"git": {
|
||||
"total": 372,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "config --get remote.origin.url", "count": 35 },
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 31 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "status --porcelain", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 17 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
|
||||
{ "key": "rev-list --count main..origin/main", "count": 7 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1871,
|
||||
"cpuSystemMs": 2152,
|
||||
"rssDeltaMb": 4.4,
|
||||
"heapUsedDeltaMb": 8.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After 2: shared service-level facts
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31334,
|
||||
"git": {
|
||||
"total": 308,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 26 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "status --porcelain", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "config --get remote.origin.url", "count": 13 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 13 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
|
||||
{ "key": "fetch origin --prune", "count": 5 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1817,
|
||||
"cpuSystemMs": 1869,
|
||||
"rssDeltaMb": 16.7,
|
||||
"heapUsedDeltaMb": 10.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Equivalence Guard
|
||||
|
||||
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
|
||||
|
||||
- `rev-parse --show-toplevel`
|
||||
- `rev-parse --abbrev-ref HEAD`
|
||||
|
||||
Test:
|
||||
|
||||
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
|
||||
|
||||
## Remaining Waste Visible In Baseline
|
||||
|
||||
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
|
||||
|
||||
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
|
||||
|
||||
- base ref existence checks still repeat as `show-ref` probes.
|
||||
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
|
||||
- repo common-dir lookup is lower, but still above the apparent git workspace count.
|
||||
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
|
||||
|
||||
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.
|
||||
@@ -0,0 +1,389 @@
|
||||
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
|
||||
|
||||
## Answer
|
||||
|
||||
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
|
||||
|
||||
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
|
||||
|
||||
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
|
||||
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
|
||||
- `/Users/moboudra`
|
||||
- `/Users/moboudra/dev/paseo`
|
||||
- `/Users/moboudra/dev/blankpage/editor`
|
||||
3. Each OpenCode snapshot runs two OpenCode SDK calls:
|
||||
- `GET /provider?directory=...` through `client.provider.list()`
|
||||
- `GET /agent?directory=...` through `client.app.agents()`
|
||||
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
|
||||
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
|
||||
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
|
||||
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
|
||||
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
|
||||
|
||||
So the root cause is:
|
||||
|
||||
```text
|
||||
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
|
||||
```
|
||||
|
||||
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
|
||||
|
||||
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
|
||||
|
||||
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
|
||||
|
||||
## Follow-up: Normal Copied-Home Startup Check
|
||||
|
||||
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
|
||||
|
||||
```text
|
||||
fetchWorkspaces
|
||||
fetchAgents
|
||||
getProvidersSnapshot(home scope)
|
||||
getProvidersSnapshot(first workspace scope)
|
||||
```
|
||||
|
||||
That run did not reproduce the 30s OpenCode timeout.
|
||||
|
||||
```text
|
||||
home scope:
|
||||
OpenCode ready at ~8s
|
||||
availability: 1.6s
|
||||
fetch total: 5.2s
|
||||
|
||||
first workspace scope:
|
||||
OpenCode ready at ~26s
|
||||
availability: 2.0s
|
||||
fetch total: 15.4s
|
||||
```
|
||||
|
||||
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
|
||||
|
||||
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
|
||||
|
||||
## Reproduction Used
|
||||
|
||||
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
|
||||
|
||||
Relevant scripts:
|
||||
|
||||
- `scripts/dev.sh`
|
||||
- `scripts/dev-daemon.sh`
|
||||
- `scripts/dev-home.sh`
|
||||
|
||||
`dev-home.sh` only seeds this metadata into the dev home:
|
||||
|
||||
```text
|
||||
agents/**/*.json
|
||||
projects/**/*.json
|
||||
config.json
|
||||
```
|
||||
|
||||
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
|
||||
|
||||
I ran a separate daemon, not the main daemon:
|
||||
|
||||
```text
|
||||
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
|
||||
PASEO_LISTEN=127.0.0.1:51116
|
||||
PASEO_LOG_LEVEL=trace
|
||||
```
|
||||
|
||||
Startup facts:
|
||||
|
||||
```text
|
||||
18:13:39.552 Agent storage initialized: 712ms
|
||||
18:13:39.559 Workspace registries bootstrapped: 719ms
|
||||
18:13:39.961 Agent registry loaded: 4851 records
|
||||
18:13:39.972 Server listening: http://127.0.0.1:51116
|
||||
```
|
||||
|
||||
The probe then connected four client sessions and requested:
|
||||
|
||||
- workspaces
|
||||
- active agents
|
||||
- provider snapshots for home, paseo, and blankpage/editor
|
||||
|
||||
Client-visible result:
|
||||
|
||||
```text
|
||||
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
|
||||
OpenCode app.agents timed out after 10s
|
||||
|
||||
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
18:14:41.688 /Users/moboudra opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
## Exact OpenCode Timeline
|
||||
|
||||
OpenCode snapshot requests began at `18:14:10`.
|
||||
|
||||
Availability checks:
|
||||
|
||||
```text
|
||||
18:14:10.780 opencode availability start for /Users/moboudra
|
||||
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
|
||||
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
|
||||
|
||||
18:14:11.363 paseo availability complete: 576ms
|
||||
18:14:11.376 home availability complete: 597ms
|
||||
18:14:11.391 blankpage availability complete: 591ms
|
||||
```
|
||||
|
||||
OpenCode server acquisition:
|
||||
|
||||
```text
|
||||
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
|
||||
18:14:19.926 OpenCode server listening after 8562ms
|
||||
```
|
||||
|
||||
Six SDK calls were then issued:
|
||||
|
||||
```text
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
|
||||
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Why six:
|
||||
|
||||
| Cwd | Why that scope exists | Model call | Mode call |
|
||||
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
||||
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
|
||||
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
|
||||
|
||||
Headers arrived before the 30s timeout:
|
||||
|
||||
| Call | Cwd | Headers after request |
|
||||
| ----------- | ------------------------------- | --------------------- |
|
||||
| `/provider` | `/Users/moboudra` | 6.462s |
|
||||
| `/agent` | `/Users/moboudra` | 6.681s |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
|
||||
|
||||
But body consumption and completion lagged:
|
||||
|
||||
```text
|
||||
18:14:29.380 /agent home complete, total app.agents duration 9450ms
|
||||
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
|
||||
18:14:30.263 /agent blankpage timed out at 10s
|
||||
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
|
||||
|
||||
18:14:41.687 paseo snapshot outer 30s timeout fires
|
||||
18:14:41.688 home snapshot outer 30s timeout fires
|
||||
|
||||
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
|
||||
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
|
||||
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
|
||||
```
|
||||
|
||||
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
|
||||
|
||||
## Why Settings Refresh Works
|
||||
|
||||
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
|
||||
|
||||
Results:
|
||||
|
||||
```text
|
||||
home refresh:
|
||||
total: 2165ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/paseo refresh:
|
||||
total: 1675ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/blankpage/editor refresh:
|
||||
total: 1794ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
```
|
||||
|
||||
Trace details for the manual-style refresh:
|
||||
|
||||
```text
|
||||
OpenCode server acquisition: 708ms-1291ms
|
||||
/agent completion: 433ms-592ms after request start
|
||||
/provider completion: 524ms-618ms after request start
|
||||
```
|
||||
|
||||
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
|
||||
|
||||
## Minimal OpenCode-Only Repros
|
||||
|
||||
### OpenCode Only, No Daemon, No Artificial Load
|
||||
|
||||
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
|
||||
|
||||
```text
|
||||
GET /provider?directory=/Users/moboudra
|
||||
GET /agent?directory=/Users/moboudra
|
||||
GET /provider?directory=/Users/moboudra/dev/paseo
|
||||
GET /agent?directory=/Users/moboudra/dev/paseo
|
||||
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
|
||||
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Three runs:
|
||||
|
||||
| Run | `opencode serve` ready | All six calls complete |
|
||||
| --- | ---------------------- | ---------------------- |
|
||||
| 1 | 1376ms | 1295ms |
|
||||
| 2 | 906ms | 1050ms |
|
||||
| 3 | 939ms | 898ms |
|
||||
|
||||
Slowest individual call in those runs:
|
||||
|
||||
```text
|
||||
/provider /Users/moboudra/dev/paseo: 1270ms total
|
||||
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
|
||||
```
|
||||
|
||||
So six concurrent OpenCode calls alone are not the bug.
|
||||
|
||||
### OpenCode Only Plus External Git Storm, No Daemon
|
||||
|
||||
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
opencode serve ready: 15479ms
|
||||
all six OpenCode calls complete: 15176ms after server ready
|
||||
combined cold-start + calls: about 30655ms
|
||||
```
|
||||
|
||||
Individual calls under the external git storm:
|
||||
|
||||
| Call | Cwd | Total |
|
||||
| ----------- | -------------------------------------- | ------: |
|
||||
| `/provider` | `/Users/moboudra` | 10684ms |
|
||||
| `/agent` | `/Users/moboudra` | 10767ms |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
|
||||
|
||||
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
|
||||
|
||||
## Why It Does Not Retry
|
||||
|
||||
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
|
||||
|
||||
- no existing snapshot
|
||||
- missing providers
|
||||
- entries still in `loading` with no active load
|
||||
|
||||
When refresh fails, `refreshProvider()` stores:
|
||||
|
||||
```text
|
||||
status: "error"
|
||||
error: "Timed out refreshing OpenCode after 30000ms"
|
||||
```
|
||||
|
||||
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
|
||||
|
||||
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
|
||||
|
||||
```text
|
||||
refreshSettingsSnapshot()
|
||||
clearCachedProviders()
|
||||
resetSnapshotToLoading()
|
||||
refreshProviders(... force: true)
|
||||
```
|
||||
|
||||
That is why you have to force a manual refresh.
|
||||
|
||||
## Git Work During The Repro
|
||||
|
||||
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
|
||||
|
||||
Total git commands in the dev-style copied-home daemon log:
|
||||
|
||||
```text
|
||||
632 spawned
|
||||
632 closed
|
||||
```
|
||||
|
||||
Top cwd counts:
|
||||
|
||||
| Count | Cwd |
|
||||
| ----: | ------------------------------------------------------------------------------------- |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
|
||||
| 24 | `/Users/moboudra/dev/paseo` |
|
||||
| 24 | `/Users/moboudra/dev/blankpage/editor` |
|
||||
| 24 | `/Users/moboudra/dev/faro/main` |
|
||||
| 24 | `/Users/moboudra/dev/konbert/web` |
|
||||
| 24 | `/Users/moboudra/dev/paseo-cloud` |
|
||||
|
||||
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
|
||||
|
||||
```text
|
||||
142 git command spawns
|
||||
150 git command closes
|
||||
```
|
||||
|
||||
The main repeated command shapes were:
|
||||
|
||||
```text
|
||||
76 git rev-parse --show-toplevel
|
||||
72 git status --porcelain
|
||||
72 git show-ref --verify --quiet refs/remotes/origin/main
|
||||
72 git show-ref --verify --quiet refs/heads/main
|
||||
16 git config --get branch.main.remote
|
||||
16 git config --get branch.main.merge
|
||||
16 git rev-list --count main..origin/main
|
||||
16 git rev-list --count origin/main..main
|
||||
```
|
||||
|
||||
## Original `log.txt` Alignment
|
||||
|
||||
The original startup showed the same home and paseo outer timeout shape:
|
||||
|
||||
```text
|
||||
16:04:22.466 /Users/moboudra/dev/paseo:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
16:04:22.482 /Users/moboudra:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
|
||||
|
||||
## Files Instrumented For Diagnosis
|
||||
|
||||
Temporary trace instrumentation was added to:
|
||||
|
||||
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
|
||||
|
||||
The instrumentation is behavior-neutral and only emits trace logs.
|
||||
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Daemon Startup Sequence Analysis - 2026-05-27
|
||||
|
||||
Source log: `log.txt` at repository root.
|
||||
|
||||
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
|
||||
|
||||
This report is descriptive only. It does not propose optimizations.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
|
||||
|
||||
- Worker start: `16:03:46.678`, line 1.
|
||||
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
|
||||
- First client hello: `16:03:50.285`, line 66.
|
||||
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
|
||||
|
||||
The startup shape is therefore:
|
||||
|
||||
- Daemon listen readiness: about `0.6s`.
|
||||
- Client reconnect plus workspace/app/provider hydration: about `45s`.
|
||||
- No git commands after workspace registry reconciliation in this slice.
|
||||
|
||||
## Method
|
||||
|
||||
I parsed structured trace lines from `log.txt`, especially:
|
||||
|
||||
- `Git command closed`
|
||||
- `agent.session.inbound`
|
||||
- `agent.session.outbound`
|
||||
- `ws_slow_request`
|
||||
- provider snapshot warnings
|
||||
- provider resume events
|
||||
|
||||
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
|
||||
|
||||
Relevant code paths checked:
|
||||
|
||||
- `packages/server/src/server/session.ts`
|
||||
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
|
||||
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
|
||||
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
|
||||
- `packages/server/src/server/workspace-git-service.ts`
|
||||
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
|
||||
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
|
||||
- `packages/app/src/contexts/session-context.tsx`
|
||||
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
|
||||
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
|
||||
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ------------------------------------------------------------------ |
|
||||
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
|
||||
| `16:03:47.683` | 4 | worker spawned |
|
||||
| `16:03:47.684` | 6 | daemon keypair loaded |
|
||||
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
|
||||
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
|
||||
| `16:03:50.274` | 60 | first websocket awaiting hello |
|
||||
| `16:03:50.285` | 66 | first client connected via hello |
|
||||
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
|
||||
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
|
||||
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
|
||||
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
|
||||
| `16:04:24.306` | 1214 | OpenCode server connected event |
|
||||
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
|
||||
| `16:04:33.666` | 1777 | workspace registries reconciled |
|
||||
| `16:04:34.197` | 1783 | OpenCode heartbeat |
|
||||
| `16:04:44.200` | 1789 | OpenCode heartbeat |
|
||||
|
||||
## Git Command Totals
|
||||
|
||||
Total git commands in the sliced startup: `444`.
|
||||
|
||||
| phase | commands | failures | summed process time |
|
||||
| ------------------------------- | -------: | -------: | ------------------: |
|
||||
| daemon bootstrap before listen | 13 | 4 | 445ms |
|
||||
| post-listen before first client | 1 | 0 | 2020ms |
|
||||
| client reconnect + reconcile | 430 | 71 | 120813ms |
|
||||
| after reconcile | 0 | 0 | 0ms |
|
||||
| total | 444 | 75 | 123278ms |
|
||||
|
||||
Summed process time is not wall-clock time. Many commands overlap.
|
||||
|
||||
## Git Command Categories
|
||||
|
||||
| category | commands | failures | summed process time | max duration |
|
||||
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
|
||||
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
|
||||
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
|
||||
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
|
||||
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
|
||||
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
|
||||
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
|
||||
|
||||
What those categories mean in the app:
|
||||
|
||||
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
|
||||
- Dirty status: show dirty/clean workspace state.
|
||||
- Upstream config and ahead/behind: show branch tracking and sync state.
|
||||
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
|
||||
|
||||
## Per-Workspace Git Work
|
||||
|
||||
Columns:
|
||||
|
||||
- `phase`: `pre/warm/reconnect/after`
|
||||
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
|
||||
- `total_ms`: summed process time for that workspace
|
||||
|
||||
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
|
||||
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
|
||||
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
|
||||
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
|
||||
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
|
||||
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
|
||||
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
|
||||
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
|
||||
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
|
||||
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
|
||||
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
|
||||
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
|
||||
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
|
||||
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
|
||||
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
|
||||
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
|
||||
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
|
||||
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
|
||||
|
||||
## Git Failure Shape
|
||||
|
||||
There were 75 nonzero git exits.
|
||||
|
||||
Most failures were not timeouts. They were expected probe failures:
|
||||
|
||||
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
|
||||
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
|
||||
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
|
||||
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
|
||||
|
||||
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
|
||||
|
||||
## Inbound Client Work
|
||||
|
||||
Inbound session messages during the startup window:
|
||||
|
||||
| request | count |
|
||||
| --------------------------------- | ----: |
|
||||
| `client_heartbeat` | 19 |
|
||||
| `checkout_pr_status_request` | 18 |
|
||||
| `fetch_agents_request` | 11 |
|
||||
| `fetch_workspaces_request` | 9 |
|
||||
| `get_providers_snapshot_request` | 9 |
|
||||
| `project_icon_request` | 9 |
|
||||
| `fetch_agent_timeline_request` | 7 |
|
||||
| `clear_agent_attention` | 6 |
|
||||
| `list_terminals_request` | 5 |
|
||||
| `subscribe_terminals_request` | 5 |
|
||||
| `list_available_editors_request` | 2 |
|
||||
| `subscribe_checkout_diff_request` | 2 |
|
||||
| `checkout_status_request` | 1 |
|
||||
| `fetch_agent_request` | 1 |
|
||||
| `file_explorer_request` | 1 |
|
||||
| `read_project_config_request` | 1 |
|
||||
| `workspace_setup_status_request` | 1 |
|
||||
|
||||
Inbound by client:
|
||||
|
||||
| client | count | top work |
|
||||
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
|
||||
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
|
||||
|
||||
## Outbound Client Work
|
||||
|
||||
Outbound session messages during the startup window:
|
||||
|
||||
| message | count |
|
||||
| ---------------------------------- | ----: |
|
||||
| `providers_snapshot_update` | 129 |
|
||||
| `workspace_update` | 81 |
|
||||
| `checkout_status_update` | 76 |
|
||||
| `agent_update` | 47 |
|
||||
| `checkout_pr_status_response` | 18 |
|
||||
| `fetch_agents_response` | 11 |
|
||||
| `fetch_workspaces_response` | 9 |
|
||||
| `get_providers_snapshot_response` | 9 |
|
||||
| `project_icon_response` | 9 |
|
||||
| `fetch_agent_timeline_response` | 7 |
|
||||
| `list_terminals_response` | 5 |
|
||||
| `terminals_changed` | 5 |
|
||||
| `list_available_editors_response` | 2 |
|
||||
| `subscribe_checkout_diff_response` | 2 |
|
||||
| `checkout_status_response` | 1 |
|
||||
| `fetch_agent_response` | 1 |
|
||||
| `file_explorer_response` | 1 |
|
||||
| `read_project_config_response` | 1 |
|
||||
| `workspace_setup_status_response` | 1 |
|
||||
|
||||
Provider snapshot updates were large and repeated:
|
||||
|
||||
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
|
||||
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
|
||||
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
|
||||
|
||||
## Slow Requests
|
||||
|
||||
Slow requests logged during startup:
|
||||
|
||||
| time | request | duration | client | line |
|
||||
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
|
||||
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
|
||||
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
|
||||
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
|
||||
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
|
||||
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
|
||||
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
|
||||
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
|
||||
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
|
||||
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
|
||||
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
|
||||
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
|
||||
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
|
||||
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
|
||||
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
|
||||
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
|
||||
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
|
||||
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
|
||||
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
|
||||
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
|
||||
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
|
||||
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
|
||||
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
|
||||
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
|
||||
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
|
||||
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
|
||||
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
|
||||
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
|
||||
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
|
||||
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
|
||||
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
|
||||
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
|
||||
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
|
||||
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
|
||||
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
|
||||
|
||||
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
|
||||
|
||||
## Provider Findings
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode provider snapshot refresh had two timeouts:
|
||||
|
||||
| time | line | cwd | error |
|
||||
| -------------- | ---: | --------------------------- | --------------------------------------------- |
|
||||
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
|
||||
These are provider snapshot failures, not OpenCode agent resume failures.
|
||||
|
||||
The persisted OpenCode agent did resume:
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ----------------------------------------------------- |
|
||||
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
|
||||
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
|
||||
| `16:04:24.306` | 1214 | raw event `server.connected` |
|
||||
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
|
||||
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
|
||||
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
|
||||
|
||||
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
|
||||
|
||||
OpenCode-related git:
|
||||
|
||||
- `~/dev/opencode` had 22 git commands.
|
||||
- Four failed.
|
||||
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
|
||||
- Those failures are git state/probe failures, not OpenCode provider process failures.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex provider startup observations:
|
||||
|
||||
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
|
||||
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
|
||||
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
|
||||
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
|
||||
|
||||
### Claude
|
||||
|
||||
Claude agents resume successfully:
|
||||
|
||||
| time | line | client | agent |
|
||||
| -------------- | ---: | -------- | -------------------------------------- |
|
||||
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
|
||||
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
|
||||
|
||||
## What Work The App Asked For
|
||||
|
||||
The startup work visible in the app/server protocol is:
|
||||
|
||||
- Workspace list/sidebar hydration:
|
||||
- `fetch_workspaces_request`, 9 total.
|
||||
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
|
||||
- On the server this triggers workspace git observer sync and workspace update flushing.
|
||||
|
||||
- Agent list and agent detail hydration:
|
||||
- `fetch_agents_request`, 11 total.
|
||||
- `fetch_agent_request`, 1 total.
|
||||
- `fetch_agent_timeline_request`, 7 total.
|
||||
- Timeline requests are among the slowest requests in this slice.
|
||||
|
||||
- Checkout/PR status UI:
|
||||
- `checkout_pr_status_request`, 18 total, all Electron.
|
||||
- `checkout_status_request`, 1 total.
|
||||
- `subscribe_checkout_diff_request`, 2 total.
|
||||
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
|
||||
|
||||
- Provider/model/mode UI:
|
||||
- `get_providers_snapshot_request`, 9 total.
|
||||
- `providers_snapshot_update`, 129 outbound updates.
|
||||
- OpenCode provider snapshot refresh times out twice during this flow.
|
||||
|
||||
- Workspace chrome:
|
||||
- `project_icon_request`, 9 total.
|
||||
- `file_explorer_request`, 1 total.
|
||||
|
||||
- Terminal panel:
|
||||
- `list_terminals_request`, 5 total.
|
||||
- `subscribe_terminals_request`, 5 total.
|
||||
- `terminals_changed`, 5 outbound updates.
|
||||
|
||||
- Attention state:
|
||||
- `clear_agent_attention`, 6 total.
|
||||
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
|
||||
|
||||
## Concrete Waste-Looking Work, Without Optimizing Yet
|
||||
|
||||
The log shows repeated work in these exact forms:
|
||||
|
||||
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
|
||||
- Several workspaces get repeated full checkout snapshot patterns:
|
||||
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
|
||||
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
|
||||
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
|
||||
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
|
||||
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
|
||||
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
|
||||
|
||||
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.
|
||||
179
docs/floating-panels.md
Normal file
179
docs/floating-panels.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Floating Panels
|
||||
|
||||
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
|
||||
float above an anchor element on iOS, Android, and web. This doc captures the
|
||||
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
|
||||
canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
input focused while its scrollable list lives in a Portal. There is no shared
|
||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||
until then prefer copying the closest file and trimming.
|
||||
|
||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||
|
||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||
correctly (with `overflow: visible`, the default) but **does not receive touch
|
||||
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
|
||||
rect first, then iterates children. A touch in the overflowing region never
|
||||
reaches the parent, let alone the child. iOS and web do not share this rule —
|
||||
iOS hit-test descends into overflowing children, web uses standard CSS pointer
|
||||
events. This is the bug that put autocomplete on this path: the popover was
|
||||
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
|
||||
Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
full-screen. This is the right default when you must keep IME attachment.
|
||||
Choose the host by layer: app-global overlays use the root host; content
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
A Portal escapes Android's hit-test, but it also escapes two things you were
|
||||
quietly relying on:
|
||||
|
||||
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
|
||||
component's natural ancestor chain. When the user navigates away, your
|
||||
component may stay mounted (offscreen, in a tab) — the popover stays with it.
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||
together because they share the SharedValue. A portal'd popover is outside
|
||||
the composer tree — it does not get that transform unless you apply it
|
||||
yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
|
||||
renders inside its host, not necessarily at window origin. Position anchored
|
||||
content relative to the host: `anchorRect - hostRect`. This is what
|
||||
`measureFloatingPanelPortalHost()` is for.
|
||||
|
||||
The fix for transforms is Gotcha 3.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
```ts
|
||||
left = anchorRect.x - hostRect.x;
|
||||
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||
```
|
||||
|
||||
Do this before adding any platform offset. If anchor and host are both measured
|
||||
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||
Only add a status-bar offset when the render surface is not measured in the same
|
||||
coordinate system. See `tooltip.tsx` for that separate case.
|
||||
|
||||
## Gotcha 5 — The two-measurement flash
|
||||
|
||||
If your popover needs `top` (or `left`) computed from both:
|
||||
|
||||
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
|
||||
- the popover's own size (`contentSize` from `onLayout`),
|
||||
|
||||
then a naïve implementation will flash through three positions on every open:
|
||||
|
||||
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
|
||||
for either measurement. Wrapper has no `width`, so the inner content lays
|
||||
out at its natural (often narrow) intrinsic width.
|
||||
2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`.
|
||||
But the stale `onLayout` from frame 1 has already set `contentSize` to the
|
||||
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
|
||||
at the wrong spot.
|
||||
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
|
||||
updates. Position snaps to the right place.
|
||||
|
||||
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
|
||||
both:
|
||||
|
||||
- **Do not mount the floating content until `anchorRect` is set.** Return
|
||||
`null` until then. This prevents the bad-width onLayout from happening at
|
||||
all.
|
||||
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
|
||||
the final width but `opacity: 0`.** The first visible paint is at the
|
||||
correct position. This is the combobox pattern —
|
||||
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
|
||||
`top: -9999` as the placeholder; the layout work still happens at -9999 and
|
||||
any subsequent state-flash is visible when you flip back.
|
||||
|
||||
The "render invisible to measure, then reveal" pattern is the canonical
|
||||
solution to chicken-and-egg positioning in this codebase. Reach for it before
|
||||
anything fancier.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
|
||||
If no, use Portal.
|
||||
2. **Does the panel need to dismiss on screen change?** Almost always yes —
|
||||
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
a known fixed max height — you might be able to use bottom-anchored
|
||||
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
||||
`contentSize` round-trip entirely. **But only if the height is genuinely
|
||||
bounded**. Verify before you commit.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
@@ -3,27 +3,34 @@
|
||||
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** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/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`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/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/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/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.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/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/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts: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`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/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/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/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`).
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## Native Chat Stream Layout
|
||||
|
||||
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
|
||||
|
||||
- chronological stream order
|
||||
- strategy-ordered array order
|
||||
- native inverted cell visual order
|
||||
|
||||
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
||||
|
||||
Platform-specific stream edges belong on `StreamStrategy`:
|
||||
|
||||
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
|
||||
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
|
||||
|
||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,17 +24,27 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
|
||||
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two steps
|
||||
|
||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
- Last-minute changes always need approval. Every time.
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
@@ -111,7 +111,9 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — 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).
|
||||
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). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
### Test setup
|
||||
|
||||
@@ -155,3 +157,12 @@ If code isn't testable, refactor it. Signs:
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
|
||||
## Two test categories, no others
|
||||
|
||||
Every test in this repo lives in exactly one of these shapes:
|
||||
|
||||
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
|
||||
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
|
||||
|
||||
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
|
||||
|
||||
@@ -4,15 +4,17 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## STOP — `useUnistyles()` Is Forbidden
|
||||
## STOP — `useUnistyles()` Is Banned
|
||||
|
||||
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
|
||||
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
|
||||
|
||||
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
|
||||
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
|
||||
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
|
||||
|
||||
Use these alternatives in order:
|
||||
|
||||
### 1. `StyleSheet.create((theme) => ...)` — default
|
||||
|
||||
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
|
||||
|
||||
(Mind the `> *` child-selector leak documented further down.)
|
||||
|
||||
### 4. Lift the read into a tiny leaf component
|
||||
### 4. There is no "last resort"
|
||||
|
||||
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
|
||||
|
||||
### 5. (Last resort) `useUnistyles()`
|
||||
|
||||
Only acceptable when both of:
|
||||
|
||||
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
|
||||
- (b) The component is small, leaf-level, and not on a hot render path.
|
||||
|
||||
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
|
||||
|
||||
### Hot-path forbidden list
|
||||
|
||||
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
|
||||
|
||||
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
|
||||
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
|
||||
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
|
||||
|
||||
## How Updates Propagate
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ pre-commit:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "package-lock.json"
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
|
||||
@@ -73,10 +73,10 @@ buildNpmPackage rec {
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
# Server workspaces (highlight + relay + protocol + client + server + cli)
|
||||
npm run build:server
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
# App workspace deps not covered by build:server
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-a4fXFcmH0dddKpaMPdWeHtB8oyajugBeWpWnGB/18yY=
|
||||
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=
|
||||
|
||||
@@ -79,8 +79,8 @@ buildNpmPackage rec {
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
npm run build:server
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
1024
package-lock.json
generated
1024
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.85",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -24,6 +24,8 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -34,16 +36,24 @@
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
@@ -63,7 +73,7 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
@@ -77,9 +87,9 @@
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
|
||||
@@ -126,7 +126,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Ten second stream)" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
|
||||
@@ -181,7 +181,7 @@ test.describe("Client slash commands", () => {
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agent, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/quit");
|
||||
await selectClientSlashCommand(page, "/qu", "/exit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
|
||||
536
packages/app/e2e/composer-autocomplete.spec.ts
Normal file
536
packages/app/e2e/composer-autocomplete.spec.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
name: "tdd",
|
||||
description: "Write a red test, verify it fails for the right reason, implement to green",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
description: "Show help for the current agent session and available slash commands",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hello",
|
||||
description: "Insert a friendly greeting prompt into the current composer",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "heapdump",
|
||||
description: "Dump the JavaScript heap for local desktop debugging",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "health",
|
||||
description: "Show runtime health checks and connection diagnostics",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Summarize recent session history",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "handoff",
|
||||
description: "Prepare a complete handoff note for another agent",
|
||||
argumentHint: "[agent]",
|
||||
},
|
||||
{
|
||||
name: "hover",
|
||||
description: "Audit hover behavior in desktop web surfaces",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "harness",
|
||||
description: "Inspect the local test harness configuration",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hydrate",
|
||||
description: "Refresh persisted state used by the current workspace",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
description: "Highlight important changes in the active diff",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "home",
|
||||
description: "Navigate back to the workspace home surface",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
description: "Inspect host connection metadata",
|
||||
argumentHint: "",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface PopoverFrame {
|
||||
exists: boolean;
|
||||
top: number;
|
||||
bottom: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
display: string;
|
||||
visibility: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PopoverFrameRecorderWindow extends Window {
|
||||
__composerAutocompleteFrames?: PopoverFrame[];
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort || daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
commands?: unknown;
|
||||
error?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
parsed.message.payload
|
||||
) {
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Forward non-JSON frames unchanged.
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
|
||||
const operation = cleanup().catch(() => undefined);
|
||||
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
): Promise<{
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const repo = await createTempGitRepo("autocomplete-popover-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Autocomplete popover regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const route = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(route);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (options?.expectWorkspaceTab !== false) {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
await expectComposerVisible(page);
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
popover.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return Number(style.opacity);
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0.95);
|
||||
const box = await popover.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Autocomplete popover did not produce a bounding box.");
|
||||
}
|
||||
return {
|
||||
top: box.y,
|
||||
bottom: box.y + box.height,
|
||||
height: box.height,
|
||||
};
|
||||
}
|
||||
|
||||
async function startPopoverFrameRecorder(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__composerAutocompleteFrames = [];
|
||||
let active = true;
|
||||
|
||||
const record = () => {
|
||||
if (!active) return;
|
||||
|
||||
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
|
||||
if (element instanceof HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: true,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
opacity: Number(style.opacity),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
} else {
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
display: "none",
|
||||
visibility: "hidden",
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(record);
|
||||
};
|
||||
|
||||
win.__stopComposerAutocompleteFrameRecorder = () => {
|
||||
active = false;
|
||||
};
|
||||
window.requestAnimationFrame(record);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__stopComposerAutocompleteFrameRecorder?.();
|
||||
return win.__composerAutocompleteFrames ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
|
||||
return frames.filter(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
}
|
||||
|
||||
function formatFrame(frame: PopoverFrame | undefined): string {
|
||||
if (!frame) return "none";
|
||||
return JSON.stringify({
|
||||
top: Math.round(frame.top),
|
||||
bottom: Math.round(frame.bottom),
|
||||
height: Math.round(frame.height),
|
||||
opacity: frame.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
const visibleFrames = visiblePopoverFrames(frames);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
|
||||
const finalFrame = visibleFrames[visibleFrames.length - 1];
|
||||
const jumpingFrame = visibleFrames.find(
|
||||
(frame) =>
|
||||
Math.abs(frame.top - finalFrame.top) > 4 ||
|
||||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
|
||||
Math.abs(frame.height - finalFrame.height) > 4,
|
||||
);
|
||||
|
||||
expect(
|
||||
jumpingFrame,
|
||||
`expected first visible popover paint to be stable; first=${formatFrame(
|
||||
visibleFrames[0],
|
||||
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const hiddenFrame = frames
|
||||
.slice(firstVisibleIndex)
|
||||
.find((frame) => !frame.exists || frame.opacity < 0.95);
|
||||
expect(
|
||||
hiddenFrame,
|
||||
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.click();
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("/");
|
||||
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverFramesStable(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not jump when deleting a slash command search", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/he");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shrinks to filtered slash command results without moving the bottom edge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const allCommands = await visiblePopoverBox(page);
|
||||
|
||||
await input.fill("/tdd");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const oneCommand = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays visible while filtering slash command results", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("tdd", { delay: 40 });
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const composerBox = await page.getByTestId("message-input-root").boundingBox();
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -321,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
}
|
||||
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
execSync("npm run build:relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -537,13 +537,22 @@ async function awaitRelayReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(): Promise<number> {
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -729,7 +738,7 @@ export default async function globalSetup() {
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay();
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -91,21 +90,11 @@ interface DaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
DaemonClientConfig,
|
||||
DaemonClientInstance
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
@@ -83,21 +82,11 @@ interface ArchiveTabDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
ArchiveTabDaemonClientConfig,
|
||||
ArchiveTabDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
15
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
15
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
||||
new (config: ClientConfig) => ClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ClientConfig) => ClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
ServerDaemonClient,
|
||||
InternalDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "close"
|
||||
@@ -48,19 +47,6 @@ function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -83,7 +69,10 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
NewWorkspaceDaemonClientConfig,
|
||||
NewWorkspaceDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
348
packages/app/e2e/helpers/rewind-flow.ts
Normal file
348
packages/app/e2e/helpers/rewind-flow.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: TerminalPerfDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string | RegExp;
|
||||
}
|
||||
|
||||
interface ProviderLaunchConfig {
|
||||
provider: RewindFlowProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return { provider, model: "haiku", modeId: "bypassPermissions" };
|
||||
case "codex":
|
||||
return {
|
||||
provider,
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return {
|
||||
provider,
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
};
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: "openrouter/google/gemini-2.5-flash-lite",
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
function visibleChatMessages(page: Page) {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
|
||||
}
|
||||
|
||||
async function transcript(
|
||||
page: Page,
|
||||
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
|
||||
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
|
||||
| "user"
|
||||
| "assistant",
|
||||
text: (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
|
||||
.trim(),
|
||||
})),
|
||||
);
|
||||
|
||||
return coalesceAssistantTurnSegments(rawMessages);
|
||||
}
|
||||
|
||||
function coalesceAssistantTurnSegments(
|
||||
messages: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const previous = transcriptMessages.at(-1);
|
||||
if (message.role === "assistant" && previous?.role === "assistant") {
|
||||
const joinedText =
|
||||
previous.text && message.text
|
||||
? `${previous.text}\n${message.text}`
|
||||
: previous.text || message.text;
|
||||
transcriptMessages[transcriptMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
text: joinedText,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
transcriptMessages.push(message);
|
||||
}
|
||||
|
||||
return transcriptMessages.filter(
|
||||
(message) => message.role !== "assistant" || message.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
|
||||
if (typeof expected === "string") {
|
||||
return actual === expected;
|
||||
}
|
||||
return expected.test(actual);
|
||||
}
|
||||
|
||||
function formatExpectedMessage(message: TranscriptMessage): string {
|
||||
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
|
||||
return `${message.role}:${text}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export async function launchAgent(input: {
|
||||
page: Page;
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
|
||||
const before = await transcript(handle.page);
|
||||
await submitMessage(handle.page, text);
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
|
||||
if (finish.status !== "idle") {
|
||||
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
|
||||
throw new Error(
|
||||
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
if (finish.final?.lastError) {
|
||||
throw new Error(finish.final.lastError);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page), { timeout: 30_000 })
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", text }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(before.length + 2);
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertChatTranscript(
|
||||
handle: Pick<AgentHandle, "page">,
|
||||
expected: TranscriptMessage[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
return JSON.stringify(actual);
|
||||
}
|
||||
const matches = actual.every(
|
||||
(message, index) =>
|
||||
message.role === expected[index]?.role &&
|
||||
expectedTextMatches(message.text, expected[index]!.text),
|
||||
);
|
||||
return matches ? "match" : JSON.stringify(actual);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("match");
|
||||
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`Expected ${expected.length} chat messages (${expected
|
||||
.map(formatExpectedMessage)
|
||||
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindMessage(
|
||||
handle: AgentHandle,
|
||||
userMessageIndex: number,
|
||||
mode: RewindFlowMode,
|
||||
): Promise<void> {
|
||||
const beforeEpoch = await fetchTimelineEpoch(handle);
|
||||
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
|
||||
const userText = userMessages[userMessageIndex]?.text;
|
||||
if (!userText) {
|
||||
throw new Error(`No user message found at index ${userMessageIndex}`);
|
||||
}
|
||||
await userMessage
|
||||
.getByText(new RegExp(escapeRegExp(userText)))
|
||||
.first()
|
||||
.hover();
|
||||
const trigger = userMessage.getByTestId("rewind-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
|
||||
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
|
||||
await expect(modeItem).toBeVisible({ timeout: 10_000 });
|
||||
await modeItem.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
|
||||
if (mode !== "files") {
|
||||
await waitForNextTimelineEpoch(handle, beforeEpoch);
|
||||
}
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertFileExists(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function assertFileMissing(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertFileContains(filePath: string, text: string): Promise<void> {
|
||||
await assertFileExists(filePath);
|
||||
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupRewindFlow(input: {
|
||||
handle?: AgentHandle;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
if (input.handle) {
|
||||
await closeAgent(input.handle);
|
||||
}
|
||||
rmSync(input.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as TerminalPerfDaemonClient & {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
}
|
||||
|
||||
async function waitForNextTimelineEpoch(
|
||||
handle: AgentHandle,
|
||||
beforeEpoch: string | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
|
||||
.not.toBe(beforeEpoch);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -29,12 +30,20 @@ export interface TerminalPerfDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -68,35 +77,36 @@ interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
TerminalPerfDaemonClientConfig,
|
||||
TerminalPerfDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
@@ -70,31 +69,16 @@ function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
{
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
},
|
||||
WorkspaceSetupDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
7
packages/app/e2e/rewind-flow.claude.real.spec.ts
Normal file
7
packages/app/e2e/rewind-flow.claude.real.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "claude",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
7
packages/app/e2e/rewind-flow.codex.real.spec.ts
Normal file
7
packages/app/e2e/rewind-flow.codex.real.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "codex",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
8
packages/app/e2e/rewind-flow.opencode.real.spec.ts
Normal file
8
packages/app/e2e/rewind-flow.opencode.real.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "opencode",
|
||||
initialRewindMode: "both",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
7
packages/app/e2e/rewind-flow.pi.real.spec.ts
Normal file
7
packages/app/e2e/rewind-flow.pi.real.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "pi",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
88
packages/app/e2e/rewind-flow.shared.ts
Normal file
88
packages/app/e2e/rewind-flow.shared.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
assertComposerIdle,
|
||||
assertFileContains,
|
||||
assertFileExists,
|
||||
assertFileMissing,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
rewindMessage,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowMode,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
|
||||
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
|
||||
|
||||
interface RewindFlowCase {
|
||||
provider: RewindFlowProvider;
|
||||
initialRewindMode?: RewindFlowMode;
|
||||
rewindMode: RewindFlowMode;
|
||||
fileReverted: boolean;
|
||||
}
|
||||
|
||||
export function defineRewindFlowSpec(input: RewindFlowCase): void {
|
||||
test.describe(`rewind flow - ${input.provider}`, () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
|
||||
await assertChatTranscript(handle, []);
|
||||
await assertComposerIdle(handle);
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FILE_PROMPT);
|
||||
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
{ role: "user", text: /Use the Write tool/ },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 1, input.rewindMode);
|
||||
if (input.fileReverted) {
|
||||
await assertFileMissing(path.join(cwd, "qa.txt"));
|
||||
} else {
|
||||
await assertFileExists(path.join(cwd, "qa.txt"));
|
||||
}
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
await assertComposerIdle(handle);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
167
packages/app/e2e/rewind-menu.ui-contract.spec.ts
Normal file
167
packages/app/e2e/rewind-menu.ui-contract.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 1);
|
||||
await submitMessage(page, secondPrompt);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await expectComposerDraft(page, firstPrompt);
|
||||
|
||||
await submitMessage(page, replacementPrompt);
|
||||
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind failure e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
|
||||
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
61
packages/app/e2e/terminal-protocol-query.spec.ts
Normal file
61
packages/app/e2e/terminal-protocol-query.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
|
||||
const OSC11_CAPTURE_SCRIPT = `
|
||||
let captured = Buffer.alloc(0);
|
||||
|
||||
function finish() {
|
||||
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write("\\x1b]11;?\\x07");
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", (chunk) => {
|
||||
captured = Buffer.concat([captured, chunk]);
|
||||
if (captured.includes(Buffer.from("rgb:"))) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
setTimeout(finish, 700);
|
||||
`;
|
||||
|
||||
test.describe("Terminal protocol queries", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
|
||||
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
|
||||
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
|
||||
try {
|
||||
await harness.openTerminal(page, { terminalId: terminalInstance.id });
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const text = await getTerminalBufferText(page);
|
||||
|
||||
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
|
||||
expect(text).not.toContain("rgb:ffff/ffff/ffff");
|
||||
} finally {
|
||||
await harness.killTerminal(terminalInstance.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
92
packages/app/e2e/user-message-contract.ui-contract.spec.ts
Normal file
92
packages/app/e2e/user-message-contract.ui-contract.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectIdleComposer(page: Page): Promise<void> {
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
await expectIdleComposer(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
await expectIdleComposer(page);
|
||||
}
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn three.",
|
||||
];
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "User message contract e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
await submitMessage(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectUserMessageCount(page, index + 1);
|
||||
await expectNoLoadingRegressionAfterIdle(page);
|
||||
}
|
||||
|
||||
await fillComposerDraft(page, "append");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
239
packages/app/e2e/workspace-agent-title-handoff.spec.ts
Normal file
239
packages/app/e2e/workspace-agent-title-handoff.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface WorkspaceTabProbeRecord {
|
||||
at: number;
|
||||
tabs: Array<{
|
||||
testId: string;
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CapturedCreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
configTitle: string | null;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
|
||||
};
|
||||
|
||||
const win = window as ProbeWindow;
|
||||
win.__workspaceTabTitleProbe?.stop();
|
||||
|
||||
const records: ProbeRecord[] = [];
|
||||
const isVisible = (element: Element): element is HTMLElement => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const snapshot = () => {
|
||||
records.push({
|
||||
at: performance.now(),
|
||||
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
|
||||
.filter(isVisible)
|
||||
.map((element) => ({
|
||||
testId: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
ariaLabel: element.getAttribute("aria-label") ?? "",
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
snapshot();
|
||||
const observer = new MutationObserver(snapshot);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-label", "class", "data-testid", "style"],
|
||||
});
|
||||
const interval = window.setInterval(snapshot, 20);
|
||||
win.__workspaceTabTitleProbe = {
|
||||
records,
|
||||
stop: () => {
|
||||
observer.disconnect();
|
||||
window.clearInterval(interval);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
|
||||
};
|
||||
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
|
||||
probe?.stop();
|
||||
return probe?.records ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
|
||||
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
|
||||
}
|
||||
|
||||
function createFrameStartsWithPrompt(
|
||||
frame: CapturedCreateAgentFrame,
|
||||
promptTitle: string,
|
||||
): boolean {
|
||||
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
|
||||
}
|
||||
|
||||
function countCreateFramesForPrompt(
|
||||
frames: CapturedCreateAgentFrame[],
|
||||
promptTitle: string,
|
||||
): number {
|
||||
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
|
||||
}
|
||||
|
||||
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
|
||||
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
|
||||
}
|
||||
|
||||
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
||||
return record.tabs.some(tabHasLoadingTitle);
|
||||
}
|
||||
|
||||
async function waitForCreatedAgentId(
|
||||
client: TerminalPerfDaemonClient,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter((entry) => entry.agent.cwd === cwd)
|
||||
.map((entry) => entry.agent.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
|
||||
if (!agent) {
|
||||
throw new Error(`Expected one created agent in ${cwd}`);
|
||||
}
|
||||
return agent.agent.id;
|
||||
}
|
||||
|
||||
async function fetchActiveAgentTitle(
|
||||
client: TerminalPerfDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
|
||||
const promptTab = page.getByRole("button", { name: promptTitle }).first();
|
||||
await expect(promptTab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
await promptTab.click({ button: "right" });
|
||||
const renameAction = page.getByText("Rename", { exact: true }).first();
|
||||
if (await renameAction.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-title-handoff-");
|
||||
|
||||
try {
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? "Failed to open test workspace");
|
||||
}
|
||||
|
||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
configTitle: typeof config.title === "string" ? config.title : null,
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
const generatedTitle = "Generated Handoff Title";
|
||||
await installWorkspaceTabProbe(page);
|
||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||
|
||||
const agentId = await waitForCreatedAgentId(client, repo.path);
|
||||
await expect
|
||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
expect(createFrames.at(-1)).toEqual({
|
||||
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
|
||||
configTitle: null,
|
||||
});
|
||||
|
||||
await waitForPromptTabAgentActions(page, promptTitle);
|
||||
|
||||
await client.updateAgent(agentId, { name: generatedTitle });
|
||||
await expect
|
||||
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
|
||||
.toBe(generatedTitle);
|
||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const records = await readWorkspaceTabProbe(page);
|
||||
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
|
||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
||||
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
||||
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
||||
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
||||
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
|
||||
|
||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||
@@ -55,6 +56,12 @@ require_command perl
|
||||
|
||||
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
||||
|
||||
if [ ! -f "$CLIENT_EXPORTS" ]; then
|
||||
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
|
||||
echo "Run: npm run build:client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||
@@ -96,7 +103,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
|
||||
throw new Error("Missing required environment for daemon project setup.");
|
||||
}
|
||||
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
|
||||
const { DaemonClient } = await import(moduleUrl);
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
|
||||
@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
||||
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
||||
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
||||
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
|
||||
|
||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||
@@ -62,9 +62,9 @@ render_flow_tree() {
|
||||
done
|
||||
}
|
||||
|
||||
if [ ! -f "$SERVER_EXPORTS" ]; then
|
||||
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
||||
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
||||
if [ ! -f "$CLIENT_EXPORTS" ]; then
|
||||
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
|
||||
echo "Run: npm run build:client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -117,7 +117,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
|
||||
throw new Error("Missing required environment for daemon project setup.");
|
||||
}
|
||||
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
|
||||
const { DaemonClient } = await import(moduleUrl);
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
|
||||
@@ -6,7 +6,6 @@ const path = require("path");
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.trim()
|
||||
@@ -68,11 +67,7 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
const origin = context.originModulePath;
|
||||
if (
|
||||
origin &&
|
||||
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
|
||||
moduleName.endsWith(".js")
|
||||
) {
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.85",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "cross-env APP_VARIANT=development expo start",
|
||||
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
"ios": "npm --prefix ../.. run build:client && expo run:ios",
|
||||
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
|
||||
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
|
||||
"web:expo": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/expo-two-way-audio": "*",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -41,15 +46,15 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/addon-ligatures": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
"expo-asset": "~12.0.12",
|
||||
@@ -75,6 +80,7 @@
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -90,6 +96,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -101,13 +108,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
|
||||
@@ -27,6 +27,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "real-provider",
|
||||
testMatch: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
BIN
packages/app/public/apple-touch-icon.png
Normal file
BIN
packages/app/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -4,10 +4,16 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="theme-color" content="#181B1A" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paseo" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
@@ -19,6 +25,8 @@
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
/* Prevent white flash before React mounts */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
27
packages/app/public/manifest.json
Normal file
27
packages/app/public/manifest.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Paseo",
|
||||
"short_name": "Paseo",
|
||||
"description": "Monitor and control local AI coding agents from anywhere.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#181B1A",
|
||||
"background_color": "#181B1A",
|
||||
"categories": ["developer", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
packages/app/public/pwa-icon-192.png
Normal file
BIN
packages/app/public/pwa-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
packages/app/public/pwa-icon-512.png
Normal file
BIN
packages/app/public/pwa-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
89
packages/app/scripts/build-terminal-webview-html.mjs
Normal file
89
packages/app/scripts/build-terminal-webview-html.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.js"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
build.onResolve({ filter: /^@server\// }, async (args) => ({
|
||||
path: await resolveTsPath(
|
||||
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const js = result.outputFiles[0]?.text;
|
||||
if (!js) {
|
||||
throw new Error("terminal webview bundle produced no JavaScript");
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
type BottomAnchorMode,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
} from "./bottom-anchor-controller";
|
||||
import type { BottomAnchorTransportBehavior } from "./strategy";
|
||||
|
||||
type MeasurementState = ReturnType<typeof createMeasurementState>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
import type { BottomAnchorTransportBehavior } from "./strategy";
|
||||
|
||||
export type BottomAnchorMode = "sticky-bottom" | "detached";
|
||||
|
||||
292
packages/app/src/agent-stream/layout.test.ts
Normal file
292
packages/app/src/agent-stream/layout.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
block?: { groupId: string; index: number },
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "Shell",
|
||||
arguments: "echo hi",
|
||||
result: null,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
|
||||
return {
|
||||
kind: "thought",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
status: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
durationMs: 8000,
|
||||
};
|
||||
return new Map(ids.map((id) => [id, timing]));
|
||||
}
|
||||
|
||||
function strategyFor(platform: "web" | "android"): StreamStrategy {
|
||||
return resolveStreamRenderStrategy({
|
||||
platform,
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
}
|
||||
|
||||
function layoutFor(input: {
|
||||
platform: "web" | "android";
|
||||
agentStatus?: string;
|
||||
tail: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
timingIds?: string[];
|
||||
}): StreamLayout {
|
||||
const strategy = strategyFor(input.platform);
|
||||
return layoutStream({
|
||||
strategy,
|
||||
agentStatus: input.agentStatus ?? "idle",
|
||||
history: orderTailForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamItems: input.tail,
|
||||
}),
|
||||
liveHead: orderHeadForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
function footerOwners(layout: StreamLayout): string[] {
|
||||
const owners = [
|
||||
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
return owners;
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
);
|
||||
if (!item) {
|
||||
throw new Error(`Missing layout item ${id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps split assistant block spacing identical to unsplit history on %s",
|
||||
(platform) => {
|
||||
const firstBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const secondBlock = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
|
||||
const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 });
|
||||
const splitLayout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), firstBlock],
|
||||
head: [secondBlock, thirdBlock],
|
||||
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
|
||||
});
|
||||
const unsplitLayout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock],
|
||||
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).belowItem?.id).toBe(secondBlock.id);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).aboveItem?.id).toBe(firstBlock.id);
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).assistantSpacing).toBe(
|
||||
findLayoutItem(unsplitLayout, firstBlock.id).assistantSpacing,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).assistantSpacing).toBe(
|
||||
findLayoutItem(unsplitLayout, secondBlock.id).assistantSpacing,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, firstBlock.id).gapBelow,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, secondBlock.id).gapBelow,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
|
||||
});
|
||||
|
||||
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
|
||||
});
|
||||
|
||||
it("keeps the completed footer visually after the assistant after a native user reply", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.belowItem?.id).toBe("u2");
|
||||
expect(assistantRow.frameOrder).toBe("footer-then-content");
|
||||
});
|
||||
|
||||
it("keeps forward stream content before its completed footer", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.frameOrder).toBe("content-then-footer");
|
||||
});
|
||||
|
||||
it("compacts assistant block spacing across the history and live-head boundary", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps split tool sequencing and gapBelow identical to unsplit history on %s",
|
||||
(platform) => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
const assistant = assistantMessage("a1", 4);
|
||||
const splitLayout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), shell],
|
||||
head: [thinking, assistant],
|
||||
});
|
||||
const unsplitLayout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), shell, thinking, assistant],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(splitLayout, shell.id).belowItem?.id).toBe(thinking.id);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).aboveItem?.id).toBe(shell.id);
|
||||
expect(findLayoutItem(splitLayout, shell.id).toolSequence).toBe(
|
||||
findLayoutItem(unsplitLayout, shell.id).toolSequence,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).toolSequence).toBe(
|
||||
findLayoutItem(unsplitLayout, thinking.id).toolSequence,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, shell.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, shell.id).gapBelow,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, thinking.id).gapBelow,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
|
||||
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
|
||||
});
|
||||
|
||||
it("keeps bottom and inline footer ownership mutually exclusive", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
});
|
||||
246
packages/app/src/agent-stream/layout.ts
Normal file
246
packages/app/src/agent-stream/layout.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
|
||||
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
|
||||
|
||||
export interface TurnFooterHost {
|
||||
itemId: string;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
gapBelow: number;
|
||||
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
completedFooter: TurnFooterHost | null;
|
||||
toolSequence: StreamToolSequence;
|
||||
isFirstInUserGroup: boolean;
|
||||
isLastInUserGroup: boolean;
|
||||
isLastInToolSequence: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
}
|
||||
|
||||
export interface StreamLayout {
|
||||
history: StreamLayoutItem[];
|
||||
liveHead: StreamLayoutItem[];
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}
|
||||
|
||||
export interface StreamLayoutInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running"))
|
||||
);
|
||||
}
|
||||
|
||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
function getToolSequence(input: {
|
||||
item: StreamItem;
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
}): StreamToolSequence {
|
||||
if (!isToolSequenceItem(input.item)) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const hasAbove = isToolSequenceItem(input.aboveItem);
|
||||
const hasBelow = isToolSequenceItem(input.belowItem);
|
||||
if (hasAbove && hasBelow) {
|
||||
return "middle";
|
||||
}
|
||||
if (hasAbove) {
|
||||
return "last";
|
||||
}
|
||||
if (hasBelow) {
|
||||
return "first";
|
||||
}
|
||||
return "single";
|
||||
}
|
||||
|
||||
function getSegmentNeighbor(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
relation: "above" | "below";
|
||||
boundaryIndex: number | null;
|
||||
boundaryItem: StreamItem | null;
|
||||
}): StreamItem | null {
|
||||
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
|
||||
if (neighbor) {
|
||||
return neighbor;
|
||||
}
|
||||
if (input.index === input.boundaryIndex) {
|
||||
return input.boundaryItem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
items: input.items,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
|
||||
frameOrder: input.frameOrder,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
||||
const historyBoundaryItem =
|
||||
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
|
||||
const liveHeadBoundaryItem =
|
||||
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
|
||||
const frameOrder = input.strategy.getFrameChildOrder();
|
||||
const history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
});
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
liveHead,
|
||||
auxiliaryTurnFooter,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
@@ -5,12 +5,9 @@ import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
} from "./stream-strategy";
|
||||
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
|
||||
} from "./web-virtualization";
|
||||
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
export interface StreamRenderSegments {
|
||||
historyVirtualized: StreamItem[];
|
||||
@@ -22,7 +19,6 @@ export interface StreamHistoryBoundary {
|
||||
hasVirtualizedHistory: boolean;
|
||||
hasMountedHistory: boolean;
|
||||
hasLiveHead: boolean;
|
||||
historyToHeadGap: number;
|
||||
}
|
||||
|
||||
export interface StreamRenderAuxiliary {
|
||||
@@ -208,7 +204,6 @@ export function buildAgentStreamRenderModel(
|
||||
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
|
||||
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
|
||||
hasLiveHead: orderedHead.length > 0,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
auxiliary: EMPTY_AUXILIARY,
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getBottomOffsetForStreamRenderStrategy,
|
||||
getFrameChildOrderForStreamRenderStrategy,
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborIndex,
|
||||
getStreamNeighborItem,
|
||||
@@ -10,8 +13,8 @@ import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
} from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:0${seed}.000Z`);
|
||||
@@ -328,3 +331,88 @@ describe("edge slot semantics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout strategy edges", () => {
|
||||
const streamItems: StreamItem[] = [
|
||||
userMessage("u1", "user-1", 1),
|
||||
assistantMessage("a1", "assistant-1", 2),
|
||||
];
|
||||
|
||||
it("uses the newest history edge as the history/live boundary", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
|
||||
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
|
||||
|
||||
expect(
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
history: forwardHistory,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
history: invertedHistory,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the oldest live-head edge as the live-head/history boundary", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const forwardHead = orderHeadForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
streamHead: streamItems,
|
||||
});
|
||||
const invertedHead = orderHeadForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
streamHead: streamItems,
|
||||
});
|
||||
|
||||
expect(
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
liveHead: forwardHead,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
liveHead: invertedHead,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("names the frame child order needed by native inverted cells", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
|
||||
"content-then-footer",
|
||||
);
|
||||
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
|
||||
"footer-then-content",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
|
||||
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
|
||||
|
||||
function assistantBlock(params: {
|
||||
id: string;
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
} from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./stream-strategy";
|
||||
} from "./strategy";
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
@@ -380,6 +380,9 @@ export function createNativeStreamStrategy(): StreamStrategy {
|
||||
orderHeadReverse: true,
|
||||
assistantTurnTraversalStep: 1,
|
||||
edgeSlot: "header",
|
||||
historyLiveBoundaryEdge: "first",
|
||||
liveHeadHistoryBoundaryEdge: "last",
|
||||
frameChildOrder: "footer-then-content",
|
||||
flatListInverted: true,
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy";
|
||||
import { createNativeStreamStrategy } from "./stream-strategy-native";
|
||||
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./strategy";
|
||||
import { createNativeStreamStrategy } from "./strategy-native";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
export function resolveStreamRenderStrategy(
|
||||
input: ResolveStreamRenderStrategyInput,
|
||||
@@ -6,8 +6,8 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
|
||||
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
@@ -25,7 +25,7 @@ vi.hoisted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
||||
vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
return {
|
||||
@@ -125,7 +125,6 @@ describe("createWebStreamStrategy", () => {
|
||||
hasVirtualizedHistory: true,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(rowRenderCount),
|
||||
listEmptyComponent: null,
|
||||
@@ -171,7 +170,6 @@ describe("createWebStreamStrategy", () => {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
@@ -10,9 +10,9 @@ import React, {
|
||||
} from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
|
||||
import { createStreamStrategy } from "./stream-strategy";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
@@ -25,7 +25,7 @@ const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
import { useWebElementScrollbar } from "./use-web-scrollbar";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -584,9 +584,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
|
||||
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
|
||||
) : null}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
@@ -610,6 +607,9 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
|
||||
orderHeadReverse: false,
|
||||
assistantTurnTraversalStep: -1,
|
||||
edgeSlot: "footer",
|
||||
historyLiveBoundaryEdge: "last",
|
||||
liveHeadHistoryBoundaryEdge: "first",
|
||||
frameChildOrder: "content-then-footer",
|
||||
flatListInverted: false,
|
||||
overlayScrollbarInverted: false,
|
||||
maintainVisibleContentPosition: undefined,
|
||||
@@ -631,12 +631,3 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
|
||||
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
});
|
||||
}
|
||||
|
||||
interface HistoryToHeadSpacerProps {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) {
|
||||
const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]);
|
||||
return <div style={spacerStyle} />;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
BottomAnchorLocalRequest,
|
||||
BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
} from "./bottom-anchor-controller";
|
||||
|
||||
type EdgeSlot = "header" | "footer";
|
||||
type NeighborRelation = "above" | "below";
|
||||
type AssistantTurnTraversalStep = -1 | 1;
|
||||
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
|
||||
|
||||
export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||
minIndexForVisible: number;
|
||||
@@ -93,6 +94,10 @@ export interface StreamStrategy {
|
||||
) => StreamEdgeSlotProps;
|
||||
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
|
||||
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
|
||||
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
|
||||
getLatestItemIndex: (items: StreamItem[]) => number | null;
|
||||
getFrameChildOrder: () => StreamFrameChildOrder;
|
||||
getFlatListInverted: () => boolean;
|
||||
getOverlayScrollbarInverted: () => boolean;
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||
@@ -107,6 +112,9 @@ interface StreamStrategyConfig {
|
||||
orderHeadReverse: boolean;
|
||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||
edgeSlot: EdgeSlot;
|
||||
historyLiveBoundaryEdge: "first" | "last";
|
||||
liveHeadHistoryBoundaryEdge: "first" | "last";
|
||||
frameChildOrder: StreamFrameChildOrder;
|
||||
flatListInverted: boolean;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
@@ -175,6 +183,25 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
|
||||
},
|
||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
||||
getHistoryLiveBoundaryIndex: (history) => {
|
||||
if (history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return config.historyLiveBoundaryEdge === "first" ? 0 : history.length - 1;
|
||||
},
|
||||
getLiveHeadHistoryBoundaryIndex: (liveHead) => {
|
||||
if (liveHead.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return config.liveHeadHistoryBoundaryEdge === "first" ? 0 : liveHead.length - 1;
|
||||
},
|
||||
getLatestItemIndex: (items) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1;
|
||||
},
|
||||
getFrameChildOrder: () => config.frameChildOrder,
|
||||
getFlatListInverted: () => config.flatListInverted,
|
||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||
@@ -270,3 +297,23 @@ export function getStreamEdgeSlotProps(params: {
|
||||
}): StreamEdgeSlotProps {
|
||||
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||
}
|
||||
|
||||
export function getHistoryLiveBoundaryIndexForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
history: StreamItem[];
|
||||
}): number | null {
|
||||
return params.strategy.getHistoryLiveBoundaryIndex(params.history);
|
||||
}
|
||||
|
||||
export function getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
liveHead: StreamItem[];
|
||||
}): number | null {
|
||||
return params.strategy.getLiveHeadHistoryBoundaryIndex(params.liveHead);
|
||||
}
|
||||
|
||||
export function getFrameChildOrderForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
}): StreamFrameChildOrder {
|
||||
return params.strategy.getFrameChildOrder();
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
|
||||
import { SyncedLoader } from "./synced-loader";
|
||||
} from "./strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
@@ -22,52 +23,6 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
|
||||
export interface TurnFooterHost {
|
||||
itemId: string;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export function resolveBottomTurnFooterHost(input: {
|
||||
agentStatus: string;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
isInverted: boolean;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
const usesLiveHead = input.liveHead.length > 0;
|
||||
const footerItems = usesLiveHead ? input.liveHead : input.history;
|
||||
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
itemId: item.id,
|
||||
items: footerItems,
|
||||
timing: input.timingByAssistantId.get(item.id),
|
||||
startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderCompletedTurnFooter(input: {
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | undefined;
|
||||
agentStatus: string;
|
||||
suppressTurnFooter: boolean | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
!input.suppressTurnFooter &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === undefined && input.agentStatus !== "running"))
|
||||
);
|
||||
}
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
@@ -36,44 +36,32 @@ import {
|
||||
CompactionMarker,
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
} from "./message";
|
||||
import { PlanCard } from "./plan-card";
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
buildAgentStreamRenderModel,
|
||||
getStreamNeighborItem,
|
||||
resolveStreamRenderStrategy,
|
||||
type AgentStreamRenderModel,
|
||||
type StreamSegmentRenderers,
|
||||
type StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
resolveBottomTurnFooterHost,
|
||||
shouldRenderCompletedTurnFooter,
|
||||
TurnFooter,
|
||||
type TurnContentStrategy,
|
||||
type TurnFooterHost,
|
||||
} from "./agent-stream-turn-footer";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
} from "./bottom-anchor-controller";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
@@ -90,12 +78,6 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface StreamItemBoundarySeams {
|
||||
aboveItem?: StreamItem | null;
|
||||
belowItem?: StreamItem | null;
|
||||
suppressTurnFooter?: boolean;
|
||||
}
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
turnFooter: ReactNode;
|
||||
@@ -133,40 +115,39 @@ function renderPendingPermissionsNode(input: {
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
item: StreamItem;
|
||||
nextItem: StreamItem | undefined;
|
||||
items: StreamItem[];
|
||||
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
|
||||
index: number;
|
||||
agentStatus: string;
|
||||
suppressTurnFooter: boolean | undefined;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showCompletedFooter = shouldRenderCompletedTurnFooter({
|
||||
item: input.item,
|
||||
belowItem: input.nextItem,
|
||||
agentStatus: input.agentStatus,
|
||||
suppressTurnFooter: input.suppressTurnFooter,
|
||||
});
|
||||
const gapBelow = showCompletedFooter
|
||||
? 0
|
||||
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
|
||||
const footerHost = input.layoutItem.completedFooter;
|
||||
const footer = footerHost ? (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={input.strategy}
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
|
||||
);
|
||||
|
||||
if (input.layoutItem.frameOrder === "footer-then-content") {
|
||||
return (
|
||||
<>
|
||||
{footer}
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
|
||||
{showCompletedFooter ? (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={input.strategy}
|
||||
items={input.items}
|
||||
timing={input.timing.get(input.item.id)}
|
||||
startIndex={input.index}
|
||||
/>
|
||||
) : null}
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -194,47 +175,26 @@ function renderListEmptyComponent(input: {
|
||||
|
||||
function renderHistoryStreamItem(input: {
|
||||
item: StreamItem;
|
||||
historyIndexById: Map<string, number>;
|
||||
historyItems: StreamItem[];
|
||||
lastHistoryItem: StreamItem | null;
|
||||
firstLiveHeadItem: StreamItem | null;
|
||||
bottomTurnFooterHost: TurnFooterHost | null;
|
||||
renderStreamItem: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams?: StreamItemBoundarySeams,
|
||||
) => ReactNode;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
}): ReactNode {
|
||||
const historyIndex = input.historyIndexById.get(input.item.id);
|
||||
if (historyIndex === undefined) {
|
||||
const layoutItem = input.layoutItemById.get(input.item.id);
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
const seamBelowItem =
|
||||
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
|
||||
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
|
||||
belowItem: seamBelowItem,
|
||||
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
|
||||
});
|
||||
return input.renderStreamItem(layoutItem);
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
lastHistoryItem: StreamItem | null;
|
||||
bottomTurnFooterHost: TurnFooterHost | null;
|
||||
renderStreamItem: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams?: StreamItemBoundarySeams,
|
||||
) => ReactNode;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
}): ReactNode {
|
||||
return input.renderStreamItem(input.item, input.index, input.items, {
|
||||
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
|
||||
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
|
||||
});
|
||||
const layoutItem = input.layoutItemById.get(input.item.id);
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
return input.renderStreamItem(layoutItem);
|
||||
}
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
@@ -396,6 +356,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [agent.status, isMobile, streamHead, streamItems]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
strategy: streamRenderStrategy,
|
||||
agentStatus: agent.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
agent.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -432,73 +409,28 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "user_message" }>,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seamAboveItem: StreamItem | null,
|
||||
) => {
|
||||
const aboveItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
}) ??
|
||||
seamAboveItem ??
|
||||
undefined;
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isFirstInGroup = aboveItem?.kind !== "user_message";
|
||||
const isLastInGroup = belowItem?.kind !== "user_message";
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.id}
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
isLastInGroup={isLastInGroup}
|
||||
capabilities={agent.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[streamRenderStrategy],
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams: StreamItemBoundarySeams,
|
||||
) => {
|
||||
const aboveItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
}) ??
|
||||
seams.aboveItem ??
|
||||
undefined;
|
||||
const belowItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
}) ??
|
||||
seams.belowItem ??
|
||||
undefined;
|
||||
const spacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
|
||||
return (
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
@@ -513,23 +445,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={spacing}
|
||||
spacing={layoutItem.assistantSpacing}
|
||||
/>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
|
||||
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
|
||||
return (
|
||||
<ToolCallSlot
|
||||
itemId={item.id}
|
||||
@@ -537,23 +462,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toolName="thinking"
|
||||
args={item.text}
|
||||
status={item.status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[streamRenderStrategy, setInlineDetailsExpanded],
|
||||
[setInlineDetailsExpanded],
|
||||
);
|
||||
|
||||
const renderToolCallItem = useCallback(
|
||||
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => {
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const { payload } = item;
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
@@ -579,7 +497,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
detail={data.detail}
|
||||
cwd={agent.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
/>
|
||||
);
|
||||
@@ -594,33 +512,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams: StreamItemBoundarySeams = {},
|
||||
) => {
|
||||
(layoutItem: StreamLayoutItem) => {
|
||||
const item = layoutItem.item;
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
|
||||
return renderUserMessageItem(layoutItem, item);
|
||||
|
||||
case "assistant_message":
|
||||
return renderAssistantMessageItem(item, index, items, seams);
|
||||
return renderAssistantMessageItem(layoutItem, item);
|
||||
|
||||
case "thought":
|
||||
return renderThoughtItem(item, index, items);
|
||||
return renderThoughtItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return renderToolCallItem(item, index, items);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "activity_log":
|
||||
return (
|
||||
@@ -651,54 +565,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
||||
);
|
||||
|
||||
const bottomTurnFooterHost = useMemo(() => {
|
||||
return resolveBottomTurnFooterHost({
|
||||
agentStatus: agent.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
isInverted: streamRenderStrategy.getFlatListInverted(),
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
});
|
||||
}, [
|
||||
agent.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams: StreamItemBoundarySeams = {},
|
||||
) => {
|
||||
const content = renderStreamItemContent(item, index, items, seams);
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
(layoutItem: StreamLayoutItem) => {
|
||||
const content = renderStreamItemContent(layoutItem);
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
item,
|
||||
nextItem,
|
||||
items,
|
||||
timing: baseRenderModel.turnTiming.byAssistantId,
|
||||
index,
|
||||
agentStatus: agent.status,
|
||||
suppressTurnFooter: seams.suppressTurnFooter,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
});
|
||||
},
|
||||
[
|
||||
agent.status,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
renderStreamItemContent,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -735,13 +613,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
return {
|
||||
...baseRenderModel,
|
||||
boundary: {
|
||||
...baseRenderModel.boundary,
|
||||
historyToHeadGap: getGapBetweenStreamItems(
|
||||
baseRenderModel.history.at(-1) ?? null,
|
||||
baseRenderModel.segments.liveHead[0] ?? null,
|
||||
),
|
||||
},
|
||||
boundary: baseRenderModel.boundary,
|
||||
auxiliary: {
|
||||
pendingPermissions: pendingPermissionsNode,
|
||||
turnFooter: turnFooterNode,
|
||||
@@ -755,38 +627,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[renderModel, emptyStateStyle],
|
||||
);
|
||||
|
||||
const historyItems = renderModel.history;
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
const lastHistoryItem = historyItems.at(-1) ?? null;
|
||||
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
|
||||
|
||||
const historyIndexById = useMemo(() => {
|
||||
const indexById = new Map<string, number>();
|
||||
historyItems.forEach((item, index) => {
|
||||
indexById.set(item.id, index);
|
||||
});
|
||||
return indexById;
|
||||
}, [historyItems]);
|
||||
const layoutHistoryItemById = useMemo(() => {
|
||||
const itemById = new Map<string, StreamLayoutItem>();
|
||||
for (const item of streamLayout.history) {
|
||||
itemById.set(item.item.id, item);
|
||||
}
|
||||
return itemById;
|
||||
}, [streamLayout.history]);
|
||||
|
||||
const layoutLiveHeadItemById = useMemo(() => {
|
||||
const itemById = new Map<string, StreamLayoutItem>();
|
||||
for (const item of streamLayout.liveHead) {
|
||||
itemById.set(item.item.id, item);
|
||||
}
|
||||
return itemById;
|
||||
}, [streamLayout.liveHead]);
|
||||
|
||||
const renderHistoryRow = useCallback(
|
||||
(item: StreamItem) =>
|
||||
renderHistoryStreamItem({
|
||||
item,
|
||||
historyIndexById,
|
||||
historyItems,
|
||||
lastHistoryItem,
|
||||
firstLiveHeadItem,
|
||||
bottomTurnFooterHost,
|
||||
layoutItemById: layoutHistoryItemById,
|
||||
renderStreamItem,
|
||||
}),
|
||||
[
|
||||
bottomTurnFooterHost,
|
||||
firstLiveHeadItem,
|
||||
historyIndexById,
|
||||
historyItems,
|
||||
lastHistoryItem,
|
||||
renderStreamItem,
|
||||
],
|
||||
[layoutHistoryItemById, renderStreamItem],
|
||||
);
|
||||
|
||||
const renderHistoryVirtualizedRow = useCallback<
|
||||
@@ -797,16 +663,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[renderHistoryRow],
|
||||
);
|
||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||
(item, index, items) =>
|
||||
(item) =>
|
||||
renderLiveHeadStreamItem({
|
||||
item,
|
||||
index,
|
||||
items,
|
||||
lastHistoryItem,
|
||||
bottomTurnFooterHost,
|
||||
layoutItemById: layoutLiveHeadItemById,
|
||||
renderStreamItem,
|
||||
}),
|
||||
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
|
||||
[layoutLiveHeadItemById, renderStreamItem],
|
||||
);
|
||||
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
||||
return renderLiveAuxiliaryNode({
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
} from "./web-virtualization";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
@@ -28,8 +28,10 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
@@ -457,6 +459,7 @@ function AppContainer({
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
@@ -464,6 +467,7 @@ function AppContainer({
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
enabled={keyboardShortcutsEnabled}
|
||||
serverId={activeServerId}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
const { redirectMock, state } = vi.hoisted(() => {
|
||||
const hoistedState = {
|
||||
pathname: "/",
|
||||
bootstrapState: {
|
||||
splashError: null,
|
||||
retry: vi.fn(),
|
||||
hasGivenUpWaitingForHost: false,
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
return {
|
||||
redirectMock: vi.fn(),
|
||||
state: hoistedState,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
Redirect: ({ href }: { href: string }) => {
|
||||
redirectMock(href);
|
||||
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
|
||||
},
|
||||
usePathname: () => state.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_layout", () => ({
|
||||
useHostRuntimeBootstrapState: () => state.bootstrapState,
|
||||
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||
shouldUseDesktopDaemon: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
state.pathname = "/";
|
||||
state.bootstrapState = {
|
||||
splashError: null,
|
||||
retry: vi.fn(),
|
||||
hasGivenUpWaitingForHost: false,
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
redirectMock.mockReset();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function renderIndex() {
|
||||
const { default: Index } = await import("./index");
|
||||
await act(async () => {
|
||||
root.render(<Index />);
|
||||
});
|
||||
}
|
||||
|
||||
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = null;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
});
|
||||
|
||||
it("falls back to welcome when the give-up timer fires with no host online", async () => {
|
||||
state.bootstrapState = {
|
||||
...state.bootstrapState,
|
||||
hasGivenUpWaitingForHost: true,
|
||||
};
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/welcome");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
|
||||
@@ -360,6 +360,25 @@ describe("parseAssistantFileLink", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => {
|
||||
// Regressions for tool output strings like ping's "100% packet loss",
|
||||
// Windows "%PATH%" references, and percentages such as "0% off".
|
||||
// decodeURIComponent throws URIError on these; the parser must swallow
|
||||
// it and return null rather than crash the renderer.
|
||||
const cases = [
|
||||
"/tmp/100% packet loss",
|
||||
"/Users/test/project/0% off",
|
||||
"/var/log/%PATH%/x.log",
|
||||
"file:///tmp/100% packet loss",
|
||||
];
|
||||
for (const value of cases) {
|
||||
expect(() =>
|
||||
parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }),
|
||||
).not.toThrow();
|
||||
}
|
||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InlinePathTarget {
|
||||
raw: string;
|
||||
path: string;
|
||||
@@ -350,7 +358,7 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -659,7 +667,7 @@ function normalizeFileUrlPath(pathname: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(pathname).replace(/\\/g, "/");
|
||||
const decoded = safeDecodeURIComponent(pathname).replace(/\\/g, "/");
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
54
packages/app/src/attachments/attachment-file-system.ts
Normal file
54
packages/app/src/attachments/attachment-file-system.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { File } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
|
||||
export type AttachmentFileInfo =
|
||||
| { exists: true; isDirectory: boolean; size: number | null }
|
||||
| { exists: false };
|
||||
|
||||
export interface AttachmentFileSystem {
|
||||
readonly cacheDirectory: string | null;
|
||||
getInfo(uri: string): Promise<AttachmentFileInfo>;
|
||||
makeDirectory(uri: string, options: { intermediates: boolean }): Promise<void>;
|
||||
writeBytes(uri: string, bytes: Uint8Array): Promise<void>;
|
||||
copy(input: { from: string; to: string }): Promise<void>;
|
||||
readAsBase64(uri: string): Promise<string>;
|
||||
delete(uri: string, options: { idempotent: boolean }): Promise<void>;
|
||||
listDirectory(uri: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export function createExpoAttachmentFileSystem(): AttachmentFileSystem {
|
||||
return {
|
||||
cacheDirectory: FileSystem.cacheDirectory,
|
||||
async getInfo(uri) {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
if (!info.exists) {
|
||||
return { exists: false };
|
||||
}
|
||||
const size =
|
||||
typeof (info as { size?: number }).size === "number"
|
||||
? (info as { size: number }).size
|
||||
: null;
|
||||
return { exists: true, isDirectory: info.isDirectory ?? false, size };
|
||||
},
|
||||
async makeDirectory(uri, options) {
|
||||
await FileSystem.makeDirectoryAsync(uri, options);
|
||||
},
|
||||
async writeBytes(uri, bytes) {
|
||||
new File(uri).write(bytes);
|
||||
},
|
||||
async copy(input) {
|
||||
await FileSystem.copyAsync(input);
|
||||
},
|
||||
async readAsBase64(uri) {
|
||||
return await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
},
|
||||
async delete(uri, options) {
|
||||
await FileSystem.deleteAsync(uri, options);
|
||||
},
|
||||
async listDirectory(uri) {
|
||||
return await FileSystem.readDirectoryAsync(uri);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
||||
|
||||
const fileSystemMock = vi.hoisted(() => ({
|
||||
getInfoAsync: vi.fn(async (uri: string) => {
|
||||
if (uri.endsWith("/preview-assets/")) {
|
||||
return { exists: true, isDirectory: true };
|
||||
}
|
||||
return { exists: true, isDirectory: false, size: 4 };
|
||||
}),
|
||||
makeDirectoryAsync: vi.fn(async () => {}),
|
||||
writeAsStringAsync: vi.fn(async () => {}),
|
||||
copyAsync: vi.fn(async () => {}),
|
||||
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||
deleteAsync: vi.fn(async () => {}),
|
||||
readDirectoryAsync: vi.fn(async () => []),
|
||||
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
|
||||
}));
|
||||
|
||||
vi.mock("expo-file-system", () => ({
|
||||
File: class {
|
||||
uri: string;
|
||||
|
||||
constructor(uri: string) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
write(content: Uint8Array) {
|
||||
fileSystemMock.fileWrite(this.uri, content);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("expo-file-system/legacy", () => ({
|
||||
cacheDirectory: "file:///cache/",
|
||||
EncodingType: { Base64: "base64" },
|
||||
getInfoAsync: fileSystemMock.getInfoAsync,
|
||||
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
||||
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
||||
copyAsync: fileSystemMock.copyAsync,
|
||||
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
||||
deleteAsync: fileSystemMock.deleteAsync,
|
||||
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
||||
}));
|
||||
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
|
||||
|
||||
describe("local file attachment store", () => {
|
||||
beforeEach(() => {
|
||||
fileSystemMock.getInfoAsync.mockClear();
|
||||
fileSystemMock.makeDirectoryAsync.mockClear();
|
||||
fileSystemMock.writeAsStringAsync.mockClear();
|
||||
fileSystemMock.copyAsync.mockClear();
|
||||
fileSystemMock.readAsStringAsync.mockClear();
|
||||
fileSystemMock.deleteAsync.mockClear();
|
||||
fileSystemMock.readDirectoryAsync.mockClear();
|
||||
fileSystemMock.fileWrite.mockClear();
|
||||
});
|
||||
|
||||
it("writes raw byte sources directly to the managed file path", async () => {
|
||||
const fileSystem = createTestAttachmentFileSystem();
|
||||
const store = createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "preview-assets",
|
||||
fileSystem,
|
||||
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
||||
});
|
||||
|
||||
@@ -69,11 +19,6 @@ describe("local file attachment store", () => {
|
||||
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
|
||||
});
|
||||
|
||||
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
|
||||
"file:///cache/preview-assets/preview_8_test.png",
|
||||
new Uint8Array([0, 1, 2, 3]),
|
||||
);
|
||||
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
|
||||
expect(attachment).toMatchObject({
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
@@ -82,5 +27,9 @@ describe("local file attachment store", () => {
|
||||
fileName: "result.png",
|
||||
byteSize: 4,
|
||||
});
|
||||
expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual(
|
||||
new Uint8Array([0, 1, 2, 3]),
|
||||
);
|
||||
expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { File } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import type { AttachmentFileSystem } from "@/attachments/attachment-file-system";
|
||||
import {
|
||||
type AttachmentStore,
|
||||
type AttachmentStorageType,
|
||||
@@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st
|
||||
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
|
||||
}
|
||||
|
||||
async function ensureDirectory(uri: string): Promise<void> {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise<void> {
|
||||
const info = await fileSystem.getInfo(uri);
|
||||
if (info.exists && info.isDirectory) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
||||
await fileSystem.makeDirectory(uri, { intermediates: true });
|
||||
}
|
||||
|
||||
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
|
||||
@@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise<Uint8Array> {
|
||||
}
|
||||
|
||||
async function writeFromSource(input: {
|
||||
fileSystem: AttachmentFileSystem;
|
||||
source: SaveAttachmentInput["source"];
|
||||
targetUri: string;
|
||||
mimeType: string;
|
||||
@@ -64,7 +64,7 @@ async function writeFromSource(input: {
|
||||
if (from === input.targetUri) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.copyAsync({ from, to: input.targetUri });
|
||||
await input.fileSystem.copy({ from, to: input.targetUri });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ async function writeFromSource(input: {
|
||||
bytes = input.source.bytes;
|
||||
}
|
||||
|
||||
new File(input.targetUri).write(bytes);
|
||||
await input.fileSystem.writeBytes(input.targetUri, bytes);
|
||||
}
|
||||
|
||||
function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
@@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
export function createLocalFileAttachmentStore(params: {
|
||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||
baseDirectoryName: string;
|
||||
fileSystem: AttachmentFileSystem;
|
||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||
}): AttachmentStore {
|
||||
const baseDirectory = FileSystem.cacheDirectory
|
||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
const { fileSystem } = params;
|
||||
const baseDirectory = fileSystem.cacheDirectory
|
||||
? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
: null;
|
||||
|
||||
async function resolveTarget(input: SaveAttachmentInput): Promise<{
|
||||
@@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: {
|
||||
storageKey: string;
|
||||
}> {
|
||||
if (!baseDirectory) {
|
||||
throw new Error("expo-file-system cacheDirectory is unavailable.");
|
||||
throw new Error("Attachment file-system cacheDirectory is unavailable.");
|
||||
}
|
||||
|
||||
await ensureDirectory(baseDirectory);
|
||||
await ensureDirectory(fileSystem, baseDirectory);
|
||||
|
||||
const id = input.id ?? generateAttachmentId();
|
||||
let mimeTypeFromSource: string | undefined;
|
||||
@@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: {
|
||||
async save(input): Promise<AttachmentMetadata> {
|
||||
const target = await resolveTarget(input);
|
||||
await writeFromSource({
|
||||
fileSystem,
|
||||
source: input.source,
|
||||
targetUri: target.targetUri,
|
||||
mimeType: target.mimeType,
|
||||
});
|
||||
|
||||
const info = await FileSystem.getInfoAsync(target.targetUri);
|
||||
const byteSize =
|
||||
info.exists && typeof (info as { size?: number }).size === "number"
|
||||
? (info as { size: number }).size
|
||||
: null;
|
||||
const info = await fileSystem.getInfo(target.targetUri);
|
||||
const byteSize = info.exists ? info.size : null;
|
||||
return {
|
||||
id: target.id,
|
||||
mimeType: target.mimeType,
|
||||
@@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
},
|
||||
|
||||
async encodeBase64({ attachment }): Promise<string> {
|
||||
const uri = attachmentUri(attachment);
|
||||
return await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return await fileSystem.readAsBase64(attachmentUri(attachment));
|
||||
},
|
||||
|
||||
async resolvePreviewUrl({ attachment }): Promise<string> {
|
||||
@@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: {
|
||||
: {}),
|
||||
|
||||
async delete({ attachment }): Promise<void> {
|
||||
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
|
||||
await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
|
||||
},
|
||||
|
||||
async garbageCollect({ referencedIds }): Promise<void> {
|
||||
if (!baseDirectory) {
|
||||
return;
|
||||
}
|
||||
await ensureDirectory(baseDirectory);
|
||||
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
await ensureDirectory(fileSystem, baseDirectory);
|
||||
const entries = await fileSystem.listDirectory(baseDirectory);
|
||||
await Promise.all(
|
||||
entries.map(async (entryName) => {
|
||||
const id = entryName.split(".", 1)[0] ?? "";
|
||||
if (!id || referencedIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||
await fileSystem.delete(`${baseDirectory}${entryName}`, {
|
||||
idempotent: true,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system";
|
||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
@@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() {
|
||||
return createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "paseo-native-attachments",
|
||||
fileSystem: createExpoAttachmentFileSystem(),
|
||||
resolvePreviewUrl: async (attachment) => {
|
||||
if (attachment.storageKey.startsWith("file://")) {
|
||||
return attachment.storageKey;
|
||||
|
||||
@@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } =
|
||||
await import("../desktop/attachments/desktop-attachment-store");
|
||||
return createDesktopAttachmentStore();
|
||||
const { createDesktopAttachmentBridge } =
|
||||
await import("../desktop/attachments/desktop-attachment-bridge");
|
||||
return createDesktopAttachmentStore(createDesktopAttachmentBridge());
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
|
||||
|
||||
95
packages/app/src/attachments/test-attachment-file-system.ts
Normal file
95
packages/app/src/attachments/test-attachment-file-system.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
|
||||
|
||||
export interface TestAttachmentFileSystem extends AttachmentFileSystem {
|
||||
readonly files: ReadonlyMap<string, Uint8Array>;
|
||||
readonly directories: ReadonlySet<string>;
|
||||
setFile(uri: string, bytes: Uint8Array): void;
|
||||
setDirectory(uri: string): void;
|
||||
}
|
||||
|
||||
export function createTestAttachmentFileSystem(options?: {
|
||||
cacheDirectory?: string | null;
|
||||
}): TestAttachmentFileSystem {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
const directories = new Set<string>();
|
||||
const cacheDirectory =
|
||||
options && Object.hasOwn(options, "cacheDirectory")
|
||||
? (options.cacheDirectory ?? null)
|
||||
: "file:///cache/";
|
||||
|
||||
function describe(uri: string): AttachmentFileInfo {
|
||||
if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) {
|
||||
return { exists: true, isDirectory: true, size: null };
|
||||
}
|
||||
const bytes = files.get(uri);
|
||||
if (bytes) {
|
||||
return { exists: true, isDirectory: false, size: bytes.byteLength };
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
directories,
|
||||
cacheDirectory,
|
||||
setFile(uri, bytes) {
|
||||
files.set(uri, bytes);
|
||||
},
|
||||
setDirectory(uri) {
|
||||
directories.add(stripTrailingSlash(uri));
|
||||
},
|
||||
async getInfo(uri) {
|
||||
return describe(uri);
|
||||
},
|
||||
async makeDirectory(uri) {
|
||||
directories.add(stripTrailingSlash(uri));
|
||||
},
|
||||
async writeBytes(uri, bytes) {
|
||||
files.set(uri, bytes);
|
||||
},
|
||||
async copy({ from, to }) {
|
||||
const bytes = files.get(from);
|
||||
if (!bytes) {
|
||||
throw new Error(`copy: source does not exist: ${from}`);
|
||||
}
|
||||
files.set(to, bytes);
|
||||
},
|
||||
async readAsBase64(uri) {
|
||||
const bytes = files.get(uri);
|
||||
if (!bytes) {
|
||||
throw new Error(`readAsBase64: file does not exist: ${uri}`);
|
||||
}
|
||||
return toBase64(bytes);
|
||||
},
|
||||
async delete(uri, deleteOptions) {
|
||||
if (!files.delete(uri) && !deleteOptions.idempotent) {
|
||||
throw new Error(`delete: file does not exist: ${uri}`);
|
||||
}
|
||||
},
|
||||
async listDirectory(uri) {
|
||||
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
|
||||
const entries: string[] = [];
|
||||
for (const path of files.keys()) {
|
||||
if (path.startsWith(prefix)) {
|
||||
entries.push(path.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripTrailingSlash(uri: string): string {
|
||||
return uri.endsWith("/") ? uri.slice(0, -1) : uri;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
if (typeof btoa === "function") {
|
||||
return btoa(binary);
|
||||
}
|
||||
return Buffer.from(binary, "binary").toString("base64");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
|
||||
@@ -57,33 +57,41 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
}
|
||||
|
||||
describe("resolveClientSlashCommand", () => {
|
||||
it("declares the exact client commands that execute immediately", () => {
|
||||
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
|
||||
["quit", "immediate"],
|
||||
["exit", "immediate"],
|
||||
["q", "immediate"],
|
||||
["clear", "immediate"],
|
||||
["new", "immediate"],
|
||||
it("declares the exact canonical client commands with their aliases", () => {
|
||||
expect(
|
||||
CLIENT_SLASH_COMMANDS.map((command) => [
|
||||
command.name,
|
||||
[...command.aliases],
|
||||
command.execution,
|
||||
]),
|
||||
).toEqual([
|
||||
["exit", ["quit", "q"], "immediate"],
|
||||
["clear", ["new"], "immediate"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves exact client commands after trimming", () => {
|
||||
it("resolves canonical names and aliases after trimming", () => {
|
||||
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
|
||||
name: "exit",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })).toMatchObject({
|
||||
name: "exit",
|
||||
kind: "archive-agent",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })).toMatchObject({
|
||||
name: "exit",
|
||||
kind: "archive-agent",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })).toMatchObject({
|
||||
name: "clear",
|
||||
kind: "replace-agent-with-draft",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })).toMatchObject({
|
||||
name: "clear",
|
||||
kind: "replace-agent-with-draft",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
export interface ClientSlashCommand {
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
@@ -13,22 +14,9 @@ export interface ClientSlashCommand {
|
||||
}
|
||||
|
||||
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
{
|
||||
name: "quit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "q",
|
||||
aliases: ["quit", "q"],
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
@@ -36,13 +24,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
aliases: ["new"],
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
@@ -50,7 +32,13 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
|
||||
const COMMAND_BY_NAME = new Map<string, ClientSlashCommand>();
|
||||
for (const command of CLIENT_SLASH_COMMANDS) {
|
||||
COMMAND_BY_NAME.set(command.name, command);
|
||||
for (const alias of command.aliases) {
|
||||
COMMAND_BY_NAME.set(alias, command);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveClientSlashCommand(input: {
|
||||
text: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -28,9 +28,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
initialValue?: string;
|
||||
resetKey?: string | number;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
@@ -167,6 +165,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
inlineTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
@@ -202,9 +201,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.surface2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
adaptiveInputText: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
adaptiveInputPlaceholder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
@@ -237,38 +249,43 @@ export type AdaptiveTextInputProps = TextInputProps & {
|
||||
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
|
||||
// can seed it once with initialValue and remount with resetKey for real resets.
|
||||
// See https://github.com/facebook/react-native/issues/44157
|
||||
//
|
||||
// Text color and placeholder color are owned by this leaf — not the caller.
|
||||
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
|
||||
// under whatever theme is active at mount time, then keeps them mounted across
|
||||
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
|
||||
// ...)` from outside this leaf ends up with stale colors in dark mode (see
|
||||
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
|
||||
// TextInput so theme-driven re-renders land on the wrapper.
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const {
|
||||
value: _value,
|
||||
initialValue,
|
||||
resetKey,
|
||||
defaultValue,
|
||||
style,
|
||||
placeholderTextColor,
|
||||
...inputProps
|
||||
} = props;
|
||||
// Recolor the browser's :focus-visible outline (defined in public/index.html)
|
||||
// so it matches the active theme's accent instead of its hard-coded fallback.
|
||||
// Consumer style wins if it sets outlineColor explicitly.
|
||||
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
|
||||
// Leaf-owned color goes LAST so callers cannot override it with a stale
|
||||
// theme read. Outline color is theme-aware on web :focus-visible.
|
||||
const textInputProps = {
|
||||
...inputProps,
|
||||
defaultValue: initialValue ?? defaultValue,
|
||||
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
|
||||
style: [styles.adaptiveInputOutline, style],
|
||||
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
|
||||
};
|
||||
|
||||
if (isMobile && isNative) {
|
||||
return (
|
||||
<BottomSheetTextInput
|
||||
<ThemedBottomSheetTextInput
|
||||
key={resetKey}
|
||||
ref={ref as unknown as Ref<never>}
|
||||
{...textInputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
|
||||
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -344,10 +361,7 @@ export function SheetHeaderView({
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={search.placeholder ?? "Search"}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
initialValue={search.initialValue}
|
||||
resetKey={search.resetKey}
|
||||
value={search.value}
|
||||
onChangeText={handleSearchChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -364,7 +378,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
const { theme } = useUnistyles();
|
||||
const back = header.back;
|
||||
const handleBackPress = back?.onPress;
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading);
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||
if (!hasInlineRow && !header.search) return null;
|
||||
return (
|
||||
<View>
|
||||
@@ -391,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
<Text style={styles.inlineTitle} numberOfLines={1}>
|
||||
{header.title}
|
||||
</Text>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{header.search ? (
|
||||
@@ -400,10 +415,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={header.search.placeholder ?? "Search"}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
initialValue={header.search.initialValue}
|
||||
resetKey={header.search.resetKey}
|
||||
value={header.search.value}
|
||||
onChangeText={header.search.onChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -421,6 +433,8 @@ export interface AdaptiveModalSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Sticky footer rendered below the scrollable content. */
|
||||
footer?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
@@ -435,6 +449,7 @@ export function AdaptiveModalSheet({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
@@ -500,6 +515,7 @@ export function AdaptiveModalSheet({
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -512,13 +528,13 @@ export function AdaptiveModalSheet({
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@server/shared/agent-lifecycle";
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./stream-strategy";
|
||||
export * from "./stream-strategy-resolver";
|
||||
export * from "./agent-stream-render-model";
|
||||
@@ -11,9 +11,19 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Settings,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -48,7 +58,6 @@ import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
getAllProviderModelRows,
|
||||
getProviderDefaultLabel,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
@@ -79,6 +88,7 @@ interface CombinedModelSelectorProps {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
@@ -91,6 +101,7 @@ interface SelectorContentProps {
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
serverId: string | null;
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
@@ -268,42 +279,60 @@ function FavoritesSection({
|
||||
}
|
||||
|
||||
interface GroupProviderButtonProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
rowCount: number;
|
||||
defaultLabel: string | null;
|
||||
provider: ProviderSelectorProvider;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function GroupProviderButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
rowCount,
|
||||
defaultLabel,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: GroupProviderButtonProps) {
|
||||
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [
|
||||
styles.rowIconButton,
|
||||
Boolean(hovered) && styles.rowIconButtonHovered,
|
||||
pressed && styles.rowIconButtonPressed,
|
||||
];
|
||||
}
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(providerId);
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (defaultLabel) {
|
||||
onSelectDefault(providerId);
|
||||
return;
|
||||
}
|
||||
onDrillDown(providerId, providerLabel);
|
||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
onDrillDown(provider.id, provider.label);
|
||||
}, [onDrillDown, provider.id, provider.label]);
|
||||
|
||||
let stateNode: React.ReactNode;
|
||||
if (selection.kind === "models") {
|
||||
const count = selection.rows.length;
|
||||
stateNode = (
|
||||
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
|
||||
);
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.drillDownCount}>Loading</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownCount}>Error</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
{defaultLabel ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
{stateNode}
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -312,64 +341,22 @@ function GroupProviderButton({
|
||||
function GroupedProviderRows({
|
||||
providers,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{providers.map((provider, index) => {
|
||||
const rows = getProviderModelRows(provider);
|
||||
const defaultLabel = getProviderDefaultLabel(provider);
|
||||
return (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={rows.length}
|
||||
defaultLabel={defaultLabel}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{providers.map((provider, index) => (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultProviderRow({
|
||||
providerId,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
providerId: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(providerId, "");
|
||||
}, [onSelect, providerId]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Default"
|
||||
selected={isSelected}
|
||||
onPress={handlePress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelRows({
|
||||
rows,
|
||||
selectedProvider,
|
||||
@@ -430,6 +417,31 @@ function ProviderModelRows({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderErrorEmptyState({
|
||||
serverId,
|
||||
providerId,
|
||||
message,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
providerId: string;
|
||||
message: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refresh([providerId]);
|
||||
}, [refresh, providerId]);
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
|
||||
{isRefreshing ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providers,
|
||||
@@ -440,6 +452,7 @@ function SelectorContent({
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
serverId,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
@@ -461,12 +474,6 @@ function SelectorContent({
|
||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, providers],
|
||||
);
|
||||
const handleSelectDefaultProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
onSelect(providerId, "");
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
@@ -479,17 +486,28 @@ function SelectorContent({
|
||||
if (!selectedViewProvider) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||
const drillSelection = selectedViewProvider.modelSelection;
|
||||
if (drillSelection.kind === "loading") {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.emptyStateText}>Loading</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (drillSelection.kind === "error") {
|
||||
return (
|
||||
<ProviderErrorEmptyState
|
||||
serverId={serverId}
|
||||
providerId={view.providerId}
|
||||
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||
onSelect={onSelect}
|
||||
message={drillSelection.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return emptyState;
|
||||
}
|
||||
@@ -519,11 +537,7 @@ function SelectorContent({
|
||||
/>
|
||||
|
||||
{providers.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={handleSelectDefaultProvider}
|
||||
/>
|
||||
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
||||
) : null}
|
||||
|
||||
{!hasResults ? emptyState : null}
|
||||
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
|
||||
onOpen,
|
||||
onClose,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -648,6 +663,10 @@ export function CombinedModelSelector({
|
||||
handleOpenChange(!isOpen);
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
@@ -673,21 +692,42 @@ export function CombinedModelSelector({
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const openHeaderProviderSettings = useCallback(() => {
|
||||
if (!serverId || view.kind !== "provider") return;
|
||||
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
||||
handleClose();
|
||||
}, [serverId, view, handleClose]);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
return { title: "Select provider" };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openHeaderProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${view.providerLabel} settings`}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<Settings
|
||||
size={theme.iconSize.sm}
|
||||
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: ProviderIconForView ? (
|
||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : undefined,
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
actions: headerActions,
|
||||
search: {
|
||||
value: searchQuery,
|
||||
onChange: handleSearchQueryChange,
|
||||
initialValue: searchQuery,
|
||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||
placeholder: "Search models...",
|
||||
autoFocus: platformIsWeb,
|
||||
@@ -697,11 +737,15 @@ export function CombinedModelSelector({
|
||||
}, [
|
||||
view,
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openHeaderProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchQuery,
|
||||
searchResetKey,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
]);
|
||||
|
||||
@@ -760,6 +804,7 @@ export function CombinedModelSelector({
|
||||
onSelect={handleSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={handleDrillDown}
|
||||
serverId={serverId}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
@@ -857,6 +902,34 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rowStateInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowErrorText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
maxWidth: 140,
|
||||
},
|
||||
rowIconButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowSpinner: {
|
||||
transform: [{ scale: 0.7 }],
|
||||
},
|
||||
rowIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
|
||||
66
packages/app/src/components/community-links.tsx
Normal file
66
packages/app/src/components/community-links.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Heart } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { DiscordIcon } from "@/components/icons/discord-icon";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const renderGitHubIcon = (color: string) => <GitHubIcon color={color} size={14} />;
|
||||
const renderDiscordIcon = (color: string) => <DiscordIcon color={color} size={14} />;
|
||||
|
||||
export function CommunityLinks() {
|
||||
const handleOpenGitHub = useCallback(() => {
|
||||
void openExternalUrl("https://github.com/getpaseo/paseo");
|
||||
}, []);
|
||||
|
||||
const handleOpenSponsor = useCallback(() => {
|
||||
void openExternalUrl("https://github.com/sponsors/boudra");
|
||||
}, []);
|
||||
|
||||
const handleOpenDiscord = useCallback(() => {
|
||||
void openExternalUrl("https://discord.gg/jz8T2uahpH");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderGitHubIcon}
|
||||
onPress={handleOpenGitHub}
|
||||
testID="community-links-github-star"
|
||||
>
|
||||
Star
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Heart}
|
||||
onPress={handleOpenSponsor}
|
||||
testID="community-links-sponsor"
|
||||
>
|
||||
Sponsor
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderDiscordIcon}
|
||||
onPress={handleOpenDiscord}
|
||||
testID="community-links-discord"
|
||||
>
|
||||
Community
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./composer-height-mirror.native";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
|
||||
|
||||
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
|
||||
return statusControls ? "draft" : "ready";
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
interface ContextWindowMeterProps {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
totalCostUsd?: number | null;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 16;
|
||||
@@ -43,6 +44,16 @@ function formatTokenCount(value: number): string {
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function formatSessionCost(value: number): string | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (value < 0.01) {
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
@@ -57,7 +68,11 @@ function getMeterColors(
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
export function ContextWindowMeter({
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
@@ -69,6 +84,8 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
@@ -115,6 +132,9 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user