mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c83dea127 | ||
|
|
96b39d4c56 | ||
|
|
13adfee3a4 | ||
|
|
5387a16bb6 | ||
|
|
6d7848059e | ||
|
|
a324ac5d71 | ||
|
|
6f04d33b18 | ||
|
|
1526532ff4 | ||
|
|
dbff649d88 | ||
|
|
a6ad4ff430 | ||
|
|
165fc11955 | ||
|
|
426b81e5d2 | ||
|
|
2c90498cb8 | ||
|
|
c42d48a8ff | ||
|
|
54b1f524b7 | ||
|
|
85db9711b3 | ||
|
|
3c2a98b09b | ||
|
|
9e3ebd9665 | ||
|
|
a1606ea515 | ||
|
|
fa8fab75aa | ||
|
|
a3f350a7dc | ||
|
|
6a8f507d10 | ||
|
|
ea0bd47c1d | ||
|
|
f4dcb3954a | ||
|
|
7fc4dd1e86 | ||
|
|
78bb56d90d | ||
|
|
a788064e32 | ||
|
|
74f0b9da56 | ||
|
|
aa01f23ab3 | ||
|
|
ae3f1bb5df | ||
|
|
2064bac918 | ||
|
|
fa4294a0e1 | ||
|
|
c993858898 | ||
|
|
6e04153b95 | ||
|
|
a6f0d79212 | ||
|
|
4b2e0357aa | ||
|
|
acf88f4aca | ||
|
|
412c66ce4b | ||
|
|
cb906d4223 | ||
|
|
bf0d3b9786 | ||
|
|
7f37255f88 | ||
|
|
5c03e25232 | ||
|
|
b440f44111 | ||
|
|
d433e1a362 | ||
|
|
15a2e3bdcb | ||
|
|
64ba05cea5 | ||
|
|
1521ceb381 | ||
|
|
4338f5b46c | ||
|
|
e8a64fb569 | ||
|
|
cf1f849b68 | ||
|
|
bc0886ad7c | ||
|
|
12fcfe53eb | ||
|
|
a198a33ff5 | ||
|
|
7c85341a42 | ||
|
|
c534c857a4 | ||
|
|
6d8c7c4d2d | ||
|
|
f0d96f8e5a | ||
|
|
f8d4758e6b | ||
|
|
09b498da61 | ||
|
|
221b6665a0 | ||
|
|
94dd36a4c9 | ||
|
|
84c1ff560f | ||
|
|
b7f9092e93 | ||
|
|
ade05607d2 | ||
|
|
6d13796b2d | ||
|
|
191cea47a0 | ||
|
|
0aa4868b31 | ||
|
|
e7016ef6b8 | ||
|
|
4332eca9ca | ||
|
|
fc31d31493 | ||
|
|
8ba71cc189 | ||
|
|
38c1b08433 | ||
|
|
21a5d4f061 | ||
|
|
1daa131480 | ||
|
|
f33a5191df | ||
|
|
d6c3896fd9 | ||
|
|
f2da4b861b | ||
|
|
eec9804567 | ||
|
|
f58cfe9008 | ||
|
|
02adadbbc8 | ||
|
|
086bc2beee | ||
|
|
1eb3b31cfa | ||
|
|
831d0bbf6e | ||
|
|
d4fa12c3f6 | ||
|
|
f993b1d856 | ||
|
|
f3de568969 | ||
|
|
2faacc59f1 | ||
|
|
65cfe4bda4 | ||
|
|
fa20f313d3 | ||
|
|
b2247450f9 | ||
|
|
d89ecae615 | ||
|
|
24dff12652 | ||
|
|
d06e3e9494 | ||
|
|
b7802ae271 | ||
|
|
d53d601043 | ||
|
|
ca318f29cf | ||
|
|
ee1c7e6956 | ||
|
|
e3e1c08525 | ||
|
|
71e83dc42f | ||
|
|
1fcca3b32c | ||
|
|
e925aa5438 | ||
|
|
654439005a | ||
|
|
906205f97a | ||
|
|
34b95e7f01 | ||
|
|
49e3011a28 | ||
|
|
771f164e26 | ||
|
|
ce8dc46054 | ||
|
|
b6181c2d31 | ||
|
|
6225ec3008 | ||
|
|
26737d3d70 | ||
|
|
da85814940 | ||
|
|
1b5d082d1a | ||
|
|
03e54a1a21 | ||
|
|
91693aba84 | ||
|
|
f3a4732dbe | ||
|
|
90feb8050c | ||
|
|
d46d92528a | ||
|
|
bb889dae99 | ||
|
|
b6c43088fb | ||
|
|
fdc510d3a0 | ||
|
|
a64be66353 | ||
|
|
2f177dde7a | ||
|
|
8e9134d255 | ||
|
|
84796bc74d | ||
|
|
3dae11d292 | ||
|
|
4173ac0ca0 | ||
|
|
9954eefdee | ||
|
|
9800203a48 | ||
|
|
14d78327c3 | ||
|
|
6eab2a89e9 | ||
|
|
c3d2129472 | ||
|
|
829f3f7460 | ||
|
|
a4a6713550 | ||
|
|
65b814c7dc | ||
|
|
f2a23039cb | ||
|
|
c49743c4a1 |
@@ -124,6 +124,7 @@ jobs:
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.percent-escape.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/process-tree.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/terminal/worker-terminal-manager.test.ts
|
||||
@@ -137,6 +138,35 @@ jobs:
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
|
||||
desktop-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -222,7 +252,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -246,3 +281,5 @@ jobs:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||
|
||||
@@ -52,7 +52,6 @@ env:
|
||||
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
|
||||
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
|
||||
DESKTOP_WORKSPACE: "@getpaseo/desktop"
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
@@ -148,9 +147,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -167,7 +163,7 @@ jobs:
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
@@ -243,9 +239,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
@@ -260,7 +253,7 @@ jobs:
|
||||
build_args=(-- --publish never --linux --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
@@ -336,17 +329,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build workspace dependencies
|
||||
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||
|
||||
- name: Build web app for desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -358,7 +340,7 @@ jobs:
|
||||
build_args=(-- --publish never --win --x64 --arm64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
@@ -53,6 +56,7 @@
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
|
||||
+39
-7
@@ -1,21 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.65-beta.3 - 2026-05-01
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
|
||||
- The built-in daemon toggle in desktop settings now actually takes effect.
|
||||
- Desktop settings no longer reset on app launch after a legacy migration.
|
||||
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
|
||||
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
|
||||
|
||||
## 0.1.66 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
|
||||
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
|
||||
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
|
||||
|
||||
## 0.1.65 - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows:** Native ARM64 builds are now available for Snapdragon X / Copilot+ PCs.
|
||||
- Connect directly via TCP URI with SSL and optional password auth. ([#635](https://github.com/getpaseo/paseo/pull/635))
|
||||
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
|
||||
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
|
||||
- Inline review comments in the git diff pane. ([#530](https://github.com/getpaseo/paseo/pull/530))
|
||||
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
|
||||
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
|
||||
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- Pull and push your branch in one step from the git actions menu in the changes pane.
|
||||
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
|
||||
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
|
||||
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
|
||||
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
|
||||
- **Windows:** Native ARM64 builds are now available.
|
||||
- Bundled Paseo skills now refresh automatically on desktop app launch.
|
||||
|
||||
### Improved
|
||||
|
||||
- Codex streaming feels more responsive — message boundaries are preserved and output arrives sooner.
|
||||
- Terminal sessions run in a dedicated worker process for better stability.
|
||||
- New worktree branch names are derived from your prompt and attachments instead of a generic placeholder.
|
||||
- Review comment UI is cleaner and easier to scan.
|
||||
- The daemon's `/api/status` endpoint is now protected by password auth when one is configured.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -24,6 +47,12 @@
|
||||
- **Windows:** Git diff commands no longer break on paths with special characters. ([#629](https://github.com/getpaseo/paseo/pull/629))
|
||||
- Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628))
|
||||
- Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue))
|
||||
- Agent timelines refresh with smaller catch-up requests when you reopen an agent.
|
||||
- Terminal snapshots flush reliably before clients reconnect.
|
||||
- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current.
|
||||
- Voice dictation keeps recording when the agent tab loses focus.
|
||||
- OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- Codex plan approval panels no longer duplicate.
|
||||
- Imported agents display the correct title immediately.
|
||||
- OpenCode surfaces invalid mode/model errors instead of hanging.
|
||||
@@ -33,6 +62,9 @@
|
||||
- Tool detail rows on the timeline are selectable again.
|
||||
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
|
||||
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
|
||||
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
|
||||
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
|
||||
- Isolated bottom sheet modals close and re-open without getting stuck.
|
||||
|
||||
## 0.1.64 - 2026-04-28
|
||||
|
||||
|
||||
@@ -103,6 +103,14 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
|
||||
use-audio-recorder.native.ts ← uses expo-audio
|
||||
```
|
||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
|
||||
```
|
||||
components/
|
||||
browser-pane.electron.tsx ← Electron <webview> implementation
|
||||
browser-pane.web.tsx ← plain web fallback
|
||||
browser-pane.tsx ← native fallback
|
||||
```
|
||||
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
|
||||
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||
|
||||
@@ -146,7 +146,11 @@ npm run typecheck
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="100%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -137,6 +137,21 @@ Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
||||
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
||||
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
Example: adding a new enum value
|
||||
|
||||
```ts
|
||||
// 1. Add CLIENT_CAPS.newThing = "new_thing"
|
||||
// 2. Let new clients advertise it in WS hello
|
||||
// 3. Keep the shared producer schema strict
|
||||
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
||||
```
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
```
|
||||
|
||||
+13
-1
@@ -228,6 +228,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -257,7 +259,17 @@ Rules:
|
||||
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
|
||||
|
||||
Use this command to get the GitHub logins for each PR:
|
||||
|
||||
```bash
|
||||
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'
|
||||
```
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Wc/BRKf6bRq30FazffKkXcXTF9bo5WFDm6OIxwiNLb4=";
|
||||
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+200
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -23,12 +23,14 @@
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -10375,6 +10377,90 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz",
|
||||
@@ -24760,6 +24846,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/github-slugger": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -25201,6 +25293,32 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-heading-rank": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
|
||||
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-is-element": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -25228,6 +25346,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-string": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -31128,6 +31259,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint-tsgolint": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz",
|
||||
"integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsgolint": "bin/tsgolint.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint-tsgolint/darwin-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/darwin-x64": "0.22.1",
|
||||
"@oxlint-tsgolint/linux-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/linux-x64": "0.22.1",
|
||||
"@oxlint-tsgolint/win32-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/win32-x64": "0.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
@@ -33575,6 +33724,41 @@
|
||||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-autolink-headings": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
|
||||
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-is-element": "^3.0.0",
|
||||
"unified": "^11.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-slug": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
|
||||
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -38364,7 +38548,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38490,10 +38674,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.65-beta.3",
|
||||
"@getpaseo/server": "0.1.67",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38535,7 +38719,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38584,7 +38768,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38620,7 +38804,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38646,7 +38830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38661,12 +38845,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.65-beta.3",
|
||||
"@getpaseo/relay": "0.1.65-beta.3",
|
||||
"@getpaseo/highlight": "0.1.67",
|
||||
"@getpaseo/relay": "0.1.67",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -38681,6 +38865,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
@@ -39118,7 +39303,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
@@ -39129,6 +39314,8 @@
|
||||
"react": "^19.1.4",
|
||||
"react-dom": "^19.1.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"wrangler": "^4.59.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -63,7 +63,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 version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --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 --",
|
||||
"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",
|
||||
@@ -94,12 +94,14 @@
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -43,11 +43,10 @@ jobs:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
working_directory: ./packages/app
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
working_directory: ./packages/app
|
||||
run: bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +15,99 @@ const test = base.extend({
|
||||
}
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
|
||||
await provide();
|
||||
|
||||
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const entries = consoleEntries.get(page);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (testInfo.status === testInfo.expectedStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
{ auto: true },
|
||||
],
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
await handle.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
@@ -658,23 +658,6 @@ export const createAgentInRepo = async (
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(text);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function allowPermission(page: Page): Promise<void> {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||
await acceptButton.click();
|
||||
}
|
||||
|
||||
export async function denyPermission(page: Page): Promise<void> {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||
await denyButton.click();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
} as const;
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectHostConnectionType(
|
||||
page: Page,
|
||||
type: "direct" | "relay",
|
||||
): Promise<void> {
|
||||
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||
await page.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -140,7 +140,6 @@ class StartupAssertions {
|
||||
|
||||
async expectsReconnectWelcome(): Promise<this> {
|
||||
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||
|
||||
@@ -204,8 +204,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
probe.writeCount += 1;
|
||||
const preview = text
|
||||
.replaceAll("\u001b", "\\x1b")
|
||||
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const count = Math.min(from.length, to.length);
|
||||
const values: number[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
values.push(to[index]!.at - from[index]!.at);
|
||||
values.push(to[index].at - from[index].at);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const keydownToInputFrame = this.keydowns
|
||||
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||
.filter((at): at is number => at !== null)
|
||||
.map((at, index) => at - this.keydowns[index]!.at);
|
||||
.map((at, index) => at - this.keydowns[index].at);
|
||||
const inputFrameToOutputFrame = this.inputFrames
|
||||
.map((input) => {
|
||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
const event: XtermWriteEvent = {
|
||||
at: performance.now(),
|
||||
committedAt: null,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./workspace-setup";
|
||||
|
||||
export interface CreatedWorkspace {
|
||||
workspaceId: string;
|
||||
repoPath: string;
|
||||
navigateTo(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceOptions {
|
||||
worktree?: boolean;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
|
||||
|
||||
interface WorktreeRecord {
|
||||
repoPath: string;
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceHandle {
|
||||
withWorkspace: WithWorkspace;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
let client: WorkspaceSetupDaemonClient | null = null;
|
||||
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||
const worktrees: WorktreeRecord[] = [];
|
||||
|
||||
const withWorkspace: WithWorkspace = async (options) => {
|
||||
if (!client) {
|
||||
client = await connectWorkspaceSetupClient();
|
||||
}
|
||||
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
|
||||
const repo = await createTempGitRepo(prefix);
|
||||
repos.push(repo);
|
||||
|
||||
let workspacePath = repo.path;
|
||||
if (options?.worktree) {
|
||||
const tempRoot = await realpath("/tmp");
|
||||
workspacePath = path.join(
|
||||
tempRoot,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `paseo-wt-${Date.now()}`;
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{ cwd: repo.path, stdio: "ignore" },
|
||||
);
|
||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||
// Register the parent project so the sidebar lists it before we navigate.
|
||||
await client.openProject(repo.path);
|
||||
}
|
||||
|
||||
const opened = await client.openProject(workspacePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = opened.workspace.id;
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
repoPath: workspacePath,
|
||||
navigateTo: async () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await selectWorkspaceInSidebar(page, workspaceId);
|
||||
await waitForTabBar(page);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
withWorkspace,
|
||||
cleanup: async () => {
|
||||
for (const { repoPath, worktreePath } of worktrees) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so the original test failure is preserved.
|
||||
}
|
||||
}
|
||||
for (const repo of repos) {
|
||||
await repo.cleanup();
|
||||
}
|
||||
if (client) {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -265,7 +265,7 @@ export async function createWorkspaceThroughDaemon(
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export async function findWorktreeWorkspaceForProject(
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -308,7 +308,7 @@ export async function fetchWorkspaceById(
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||
const workspace = payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,35 @@ interface TempRepo {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function configureRemote(input: {
|
||||
repoPath: string;
|
||||
withRemote: boolean;
|
||||
originUrl: string | undefined;
|
||||
}): Promise<void> {
|
||||
const { repoPath, withRemote, originUrl } = input;
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
if (originUrl) {
|
||||
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
|
||||
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
originUrl?: string;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
|
||||
@@ -32,7 +32,7 @@ test.beforeAll(async () => {
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
gitlabRemoteProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
originUrl: "https://gitlab.com/acme/app.git",
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
@@ -119,4 +136,16 @@ test.describe("Projects settings", () => {
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -18,13 +19,8 @@ function getSeededDaemonPort(): string {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, serverId: string) {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
@@ -38,7 +34,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
@@ -67,7 +63,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
@@ -85,7 +81,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
|
||||
@@ -52,7 +52,7 @@ async function openProjectViaDaemon(
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,22 @@ terminalPerfDescribe("Terminal keystroke stress", () => {
|
||||
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const appObserveNodeBurstReport = await measureAppObservingNodeBurstEcho({
|
||||
page,
|
||||
harness,
|
||||
terminalName: "app-observe-node-burst",
|
||||
});
|
||||
await attachJson(testInfo, "app-observe-node-burst", appObserveNodeBurstReport);
|
||||
console.log(
|
||||
"[terminal-stress-app-observe-node-burst]",
|
||||
JSON.stringify(appObserveNodeBurstReport),
|
||||
);
|
||||
|
||||
expect(appObserveNodeBurstReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(
|
||||
INPUT_TEXT.length,
|
||||
);
|
||||
expect(appObserveNodeBurstReport.xtermWriteCount).toBeGreaterThan(0);
|
||||
|
||||
const appSmallChunksReport = await measureAppBurstEcho({
|
||||
page,
|
||||
harness,
|
||||
@@ -172,6 +188,34 @@ async function measureAppBurstEcho(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function measureAppObservingNodeBurstEcho(input: {
|
||||
page: Page;
|
||||
harness: TerminalE2EHarness;
|
||||
terminalName: string;
|
||||
}) {
|
||||
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
|
||||
try {
|
||||
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
|
||||
await input.harness.setupPrompt(input.page);
|
||||
|
||||
await resetTerminalKeystrokeStressProbe(input.page);
|
||||
|
||||
for (const char of INPUT_TEXT) {
|
||||
input.harness.client.sendTerminalInput(appTerminal.id, {
|
||||
type: "input",
|
||||
data: char,
|
||||
});
|
||||
}
|
||||
|
||||
await waitForAppStressEcho(input.page, INPUT_TEXT);
|
||||
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
|
||||
|
||||
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
|
||||
} finally {
|
||||
await input.harness.killTerminal(appTerminal.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function emitRapidAgentStreamUpdates(
|
||||
harness: TerminalE2EHarness,
|
||||
input: { agentId: string; count: number },
|
||||
@@ -234,7 +278,7 @@ async function measureDaemonBurstEcho(
|
||||
timeoutMs: STRESS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index] - sentAt);
|
||||
return {
|
||||
inputTextLength: inputText.length,
|
||||
inputFrameCount: sendTimes.length,
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -95,7 +95,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
// with direct URL navigation to the 2nd+ workspace.
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { waitForTabBar } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
} from "./helpers/workspace-lifecycle";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
// The first test after a spec-file switch can intermittently fail because
|
||||
@@ -42,154 +12,36 @@ test.describe("Workspace lifecycle", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, repo.path);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, worktreePath);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
@@ -27,9 +28,213 @@ import {
|
||||
waitForSidebarHydration,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
input: { label: string; durationMs?: number },
|
||||
): Promise<void> {
|
||||
const durationMs = input.durationMs ?? 2000;
|
||||
const startedAt = Date.now();
|
||||
const samples: string[] = [];
|
||||
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
const url = page.url();
|
||||
const text = await page
|
||||
.locator("body")
|
||||
.innerText({ timeout: 250 })
|
||||
.catch((error) => `[body unavailable: ${error instanceof Error ? error.message : error}]`);
|
||||
samples.push(`${Date.now() - startedAt}ms ${url}\n${text.slice(0, 1000)}`);
|
||||
|
||||
if (LOADING_WORKSPACE_TEXT_PATTERN.test(text)) {
|
||||
throw new Error(
|
||||
`${input.label}: loading workspace pane appeared during reconnect window.\n\n${samples.join(
|
||||
"\n\n---\n\n",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(
|
||||
sockets.map((ws) =>
|
||||
ws
|
||||
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const repo = await createTempGitRepo("workspace-reconnect-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
|
||||
workspaceIds.add(workspace.workspaceId);
|
||||
|
||||
const agent = await createIdleAgent(archiveClient, {
|
||||
cwd: repo.path,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||
|
||||
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
||||
label: "host reconnect",
|
||||
});
|
||||
daemonGate.restore();
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await monitorReconnect;
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||
});
|
||||
const workspaceId = String(workspace.id);
|
||||
const workspaceId = workspace.id;
|
||||
|
||||
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||
@@ -78,7 +78,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
const workspaceId = result.workspace.id;
|
||||
|
||||
// Navigate to the worktree workspace via sidebar click (direct URL
|
||||
// navigation for freshly created worktree workspaces can race with
|
||||
|
||||
@@ -337,7 +337,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
const workspaceId = result.workspace.id;
|
||||
|
||||
await completed;
|
||||
|
||||
|
||||
@@ -16,11 +16,34 @@ platform :ios do
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
wait_for_build_processing_to_be_complete(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
build_number: build_number.to_s,
|
||||
require "spaceship"
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
filepath: ENV.fetch("ASC_KEY_P8"),
|
||||
)
|
||||
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||
|
||||
deadline = Time.now + (30 * 60)
|
||||
loop do
|
||||
builds = Spaceship::ConnectAPI::Build.all(
|
||||
app_id: app.id,
|
||||
filter: { "version" => build_number.to_s },
|
||||
includes: "preReleaseVersion",
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
break if state == "VALID"
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
if Time.now > deadline
|
||||
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
|
||||
end
|
||||
sleep(30)
|
||||
end
|
||||
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65-beta.3",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Scale the amplitude of the thinking-tone PCM16 base64 in-place.
|
||||
// Usage: node scripts/lower-thinking-tone.mjs <gain>
|
||||
// Example: node scripts/lower-thinking-tone.mjs 0.3 (≈ -10 dB)
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const target = resolve(here, "../src/utils/thinking-tone.native-pcm.ts");
|
||||
|
||||
const gainArg = process.argv[2];
|
||||
if (!gainArg) {
|
||||
console.error("Usage: node scripts/lower-thinking-tone.mjs <gain>");
|
||||
process.exit(1);
|
||||
}
|
||||
const gain = Number(gainArg);
|
||||
if (!Number.isFinite(gain) || gain < 0) {
|
||||
console.error(`Invalid gain: ${gainArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const source = readFileSync(target, "utf8");
|
||||
const match = source.match(/"([A-Za-z0-9+/=]+)"/);
|
||||
if (!match) {
|
||||
console.error("Could not find base64 string in target file.");
|
||||
process.exit(1);
|
||||
}
|
||||
const originalBase64 = match[1];
|
||||
|
||||
const buf = Buffer.from(originalBase64, "base64");
|
||||
if (buf.byteLength % 2 !== 0) {
|
||||
console.error(`PCM16 buffer has odd length ${buf.byteLength}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let peakBefore = 0;
|
||||
let peakAfter = 0;
|
||||
const out = Buffer.alloc(buf.byteLength);
|
||||
for (let i = 0; i < buf.byteLength; i += 2) {
|
||||
const sample = buf.readInt16LE(i);
|
||||
peakBefore = Math.max(peakBefore, Math.abs(sample));
|
||||
const scaled = Math.max(-32768, Math.min(32767, Math.round(sample * gain)));
|
||||
peakAfter = Math.max(peakAfter, Math.abs(scaled));
|
||||
out.writeInt16LE(scaled, i);
|
||||
}
|
||||
|
||||
const newBase64 = out.toString("base64");
|
||||
const updated = source.replace(originalBase64, newBase64);
|
||||
writeFileSync(target, updated);
|
||||
|
||||
console.log(`Scaled thinking tone by ${gain}.`);
|
||||
console.log(` samples: ${buf.byteLength / 2}`);
|
||||
console.log(` peak abs: ${peakBefore} -> ${peakAfter}`);
|
||||
console.log(` base64 len ${originalBase64.length} -> ${newBase64.length}`);
|
||||
console.log(` wrote ${target}`);
|
||||
@@ -26,7 +26,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-g
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -50,17 +50,18 @@ import {
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
|
||||
|
||||
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
@@ -319,7 +328,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldUseDesktopDaemon(),
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -346,12 +356,18 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
||||
const storeReady =
|
||||
const isCurrentlyStoreReady =
|
||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
||||
@@ -393,7 +409,6 @@ function AppContainer({
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -407,7 +422,7 @@ function AppContainer({
|
||||
const cycleTheme = useCallback(() => {
|
||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
@@ -450,13 +465,8 @@ function AppContainer({
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
@@ -594,9 +604,6 @@ function MobileGestureWrapper({
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
@@ -609,21 +616,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
@@ -632,6 +627,24 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
const { theme } = useUnistyles();
|
||||
const surface0 = theme.colors.surface0;
|
||||
const foreground = theme.colors.foreground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isNative) return;
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: surface0,
|
||||
foregroundColor: foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [enabled, surface0, foreground]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
@@ -772,7 +785,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
@@ -911,20 +924,23 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { theme } = useUnistyles();
|
||||
const gestureRootStyle = useMemo(
|
||||
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={gestureRootStyle}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,6 +55,42 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips daemon-start when the startup gate resolves false", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
const onGateError = vi.fn();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => {
|
||||
throw new Error("settings file unreadable");
|
||||
},
|
||||
onGateError,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onGateError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
|
||||
});
|
||||
|
||||
it("does not await the daemon-start promise", () => {
|
||||
const store = createFakeStore();
|
||||
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
||||
|
||||
@@ -11,17 +11,49 @@ export interface HostRuntimeBootstrapDaemonStartService {
|
||||
start: () => Promise<DaemonStartResult>;
|
||||
}
|
||||
|
||||
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
|
||||
|
||||
export interface StartHostRuntimeBootstrapInput {
|
||||
store: HostRuntimeBootstrapStore;
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: boolean;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||
input.store.boot();
|
||||
if (input.shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService: input.daemonStartService,
|
||||
shouldStartDaemon: input.shouldStartDaemon,
|
||||
onGateError: input.onGateError,
|
||||
});
|
||||
}
|
||||
|
||||
export function startDaemonIfGateAllows(input: {
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}): void {
|
||||
const gate = input.shouldStartDaemon;
|
||||
if (typeof gate === "boolean") {
|
||||
if (gate) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => gate())
|
||||
.then((shouldStartDaemon) => {
|
||||
if (shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
export const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleDot } from "lucide-react-native";
|
||||
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
@@ -43,6 +44,16 @@ interface ComposerWorkspaceAttachmentBinding {
|
||||
}
|
||||
|
||||
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return JSON.stringify({
|
||||
type: "browser_element",
|
||||
url: attachment.attachment.url,
|
||||
selector: attachment.attachment.selector,
|
||||
tag: attachment.attachment.tag,
|
||||
text: attachment.attachment.text,
|
||||
html: attachment.attachment.outerHTML,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
type: "review",
|
||||
cwd: attachment.attachment.cwd,
|
||||
@@ -61,23 +72,32 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review";
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
return isWorkspaceAttachment(attachment) ? attachment.attachment : null;
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
key={`workspace:${args.attachment.attachment.cwd}:${args.attachment.attachment.mode}`}
|
||||
key={`workspace:${getAttachmentKey(args.attachment)}`}
|
||||
{...args}
|
||||
attachment={args.attachment}
|
||||
/>
|
||||
@@ -90,6 +110,9 @@ function useWorkspaceAttachmentBinding({
|
||||
onOpenWorkspaceAttachment,
|
||||
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
|
||||
const clearReviewDraft = useClearReviewDraft();
|
||||
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.setWorkspaceAttachments,
|
||||
);
|
||||
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
|
||||
const workspaceAttachmentKeys = useMemo(
|
||||
() => workspaceAttachments.map(getAttachmentKey),
|
||||
@@ -136,7 +159,7 @@ function useWorkspaceAttachmentBinding({
|
||||
const clearSentAttachments = useCallback(
|
||||
(attachments: readonly ComposerAttachment[]) => {
|
||||
for (const attachment of attachments) {
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "review") {
|
||||
clearReviewDraft({ key: attachment.reviewDraftKey });
|
||||
}
|
||||
}
|
||||
@@ -148,17 +171,30 @@ function useWorkspaceAttachmentBinding({
|
||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||
const selected = current[index];
|
||||
if (isWorkspaceAttachment(selected)) {
|
||||
if (selected.kind === "browser_element") {
|
||||
const selectedKey = getAttachmentKey(selected);
|
||||
const { attachmentsByScope } = useWorkspaceAttachmentsStore.getState();
|
||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
||||
const nextAttachments = attachments.filter(
|
||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
||||
);
|
||||
if (nextAttachments.length !== attachments.length) {
|
||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
suppressWorkspaceAttachment(selected);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[suppressWorkspaceAttachment],
|
||||
[setWorkspaceAttachments, suppressWorkspaceAttachment],
|
||||
);
|
||||
|
||||
const openAttachment = useCallback(
|
||||
({ attachment }: OpenWorkspaceAttachmentInput) => {
|
||||
if (!isWorkspaceAttachment(attachment)) {
|
||||
if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
|
||||
return false;
|
||||
}
|
||||
onOpenWorkspaceAttachment?.(attachment);
|
||||
@@ -216,10 +252,15 @@ function WorkspaceAttachmentPill({
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const label =
|
||||
attachment.commentCount === 1
|
||||
? "Review · 1 comment"
|
||||
: `Review · ${attachment.commentCount} comments`;
|
||||
let label: string;
|
||||
if (attachment.kind === "browser_element") {
|
||||
label = `Element · ${attachment.attachment.tag}`;
|
||||
} else {
|
||||
label =
|
||||
attachment.commentCount === 1
|
||||
? "Review · 1 comment"
|
||||
: `Review · ${attachment.commentCount} comments`;
|
||||
}
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -231,13 +272,25 @@ function WorkspaceAttachmentPill({
|
||||
testID="composer-review-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel="Open review attachment"
|
||||
removeAccessibilityLabel="Remove review attachment"
|
||||
openAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Open browser element attachment"
|
||||
: "Open review attachment"
|
||||
}
|
||||
removeAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? "Remove browser element attachment"
|
||||
: "Remove review attachment"
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.pillBody}>
|
||||
<View style={styles.pillIcon}>
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
{attachment.kind === "browser_element" ? (
|
||||
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
) : (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -279,5 +332,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -14,6 +14,21 @@ const fileSystemMock = vi.hoisted(() => ({
|
||||
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", () => ({
|
||||
@@ -37,9 +52,10 @@ describe("local file attachment store", () => {
|
||||
fileSystemMock.readAsStringAsync.mockClear();
|
||||
fileSystemMock.deleteAsync.mockClear();
|
||||
fileSystemMock.readDirectoryAsync.mockClear();
|
||||
fileSystemMock.fileWrite.mockClear();
|
||||
});
|
||||
|
||||
it("writes raw base64 sources directly to the managed file path", async () => {
|
||||
it("writes raw byte sources directly to the managed file path", async () => {
|
||||
const store = createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "preview-assets",
|
||||
@@ -50,14 +66,14 @@ describe("local file attachment store", () => {
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
fileName: "result.png",
|
||||
source: { kind: "base64", base64: "AAECAw==" },
|
||||
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
|
||||
});
|
||||
|
||||
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
||||
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
|
||||
"file:///cache/preview-assets/preview_8_test.png",
|
||||
"AAECAw==",
|
||||
{ encoding: "base64" },
|
||||
new Uint8Array([0, 1, 2, 3]),
|
||||
);
|
||||
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
|
||||
expect(attachment).toMatchObject({
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import {
|
||||
type AttachmentStore,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
type SaveAttachmentInput,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
blobToBase64,
|
||||
fileUriToPath,
|
||||
generateAttachmentId,
|
||||
getFileExtensionFromName,
|
||||
@@ -45,6 +45,15 @@ async function ensureDirectory(uri: string): Promise<void> {
|
||||
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
||||
}
|
||||
|
||||
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
|
||||
const response = await fetch(dataUrl);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
|
||||
async function writeFromSource(input: {
|
||||
source: SaveAttachmentInput["source"];
|
||||
targetUri: string;
|
||||
@@ -59,30 +68,16 @@ async function writeFromSource(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes: Uint8Array;
|
||||
if (input.source.kind === "data_url") {
|
||||
const parsed = parseDataUrl(input.source.dataUrl);
|
||||
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
|
||||
const base64 = parsed.base64;
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (mimeType !== input.mimeType) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
bytes = await dataUrlToBytes(input.source.dataUrl);
|
||||
} else if (input.source.kind === "blob") {
|
||||
bytes = await blobToBytes(input.source.blob);
|
||||
} else {
|
||||
bytes = input.source.bytes;
|
||||
}
|
||||
|
||||
if (input.source.kind === "base64") {
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = await blobToBase64(input.source.blob);
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
new File(input.targetUri).write(bytes);
|
||||
}
|
||||
|
||||
function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||
import { __setAttachmentStoreForTests } from "./store";
|
||||
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
||||
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
|
||||
|
||||
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||
return {
|
||||
@@ -54,19 +54,20 @@ describe("attachment service", () => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
||||
it("persists raw bytes without requiring a base64 wrapper", async () => {
|
||||
const store = createRecordingStore();
|
||||
__setAttachmentStoreForTests(store);
|
||||
const bytes = new Uint8Array([0, 1, 2, 3]);
|
||||
|
||||
const attachment = await persistAttachmentFromBase64({
|
||||
id: "att_base64",
|
||||
base64: "AAECAw==",
|
||||
const attachment = await persistAttachmentFromBytes({
|
||||
id: "att_bytes",
|
||||
bytes,
|
||||
mimeType: "image/png",
|
||||
fileName: "image.png",
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
id: "att_base64",
|
||||
id: "att_bytes",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "att_1",
|
||||
@@ -76,10 +77,10 @@ describe("attachment service", () => {
|
||||
});
|
||||
expect(store.savedSources).toEqual([
|
||||
{
|
||||
id: "att_base64",
|
||||
id: "att_bytes",
|
||||
mimeType: "image/png",
|
||||
fileName: "image.png",
|
||||
source: { kind: "base64", base64: "AAECAw==" },
|
||||
source: { kind: "bytes", bytes },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromBase64(input: {
|
||||
base64: string;
|
||||
export async function persistAttachmentFromBytes(input: {
|
||||
bytes: Uint8Array;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
@@ -42,7 +42,7 @@ export async function persistAttachmentFromBase64(input: {
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
source: { kind: "base64", base64: input.base64 },
|
||||
source: { kind: "bytes", bytes: input.bytes },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,38 @@ export interface AttachmentMetadata {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface BrowserElementAttachment {
|
||||
url: string;
|
||||
selector: string;
|
||||
tag: string;
|
||||
text: string;
|
||||
outerHTML: string;
|
||||
computedStyles: Record<string, string>;
|
||||
boundingRect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
reactSource: {
|
||||
fileName: string | null;
|
||||
lineNumber: number | null;
|
||||
columnNumber: number | null;
|
||||
componentName: string | null;
|
||||
} | null;
|
||||
parentChain: string[];
|
||||
children: string[];
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type ComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem }
|
||||
| {
|
||||
kind: "browser_element";
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
@@ -28,12 +56,18 @@ export type ComposerAttachment =
|
||||
commentCount: number;
|
||||
};
|
||||
|
||||
export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review" }>;
|
||||
export type UserComposerAttachment = Exclude<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
|
||||
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||
export type WorkspaceComposerAttachment = Extract<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "base64"; base64: string }
|
||||
| { kind: "bytes"; bytes: Uint8Array }
|
||||
| { kind: "blob"; blob: Blob }
|
||||
| { kind: "data_url"; dataUrl: string }
|
||||
| { kind: "file_uri"; uri: string };
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class FakeRequest<T = unknown> {
|
||||
result!: T;
|
||||
error: Error | null = null;
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
addEventListener(event: string, listener: Listener): void {
|
||||
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
|
||||
}
|
||||
|
||||
emit(event: string): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeObjectStore {
|
||||
constructor(private readonly onPut: (record: unknown) => void) {}
|
||||
|
||||
put(record: unknown): FakeRequest {
|
||||
this.onPut(record);
|
||||
const request = new FakeRequest();
|
||||
queueMicrotask(() => request.emit("success"));
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTransaction {
|
||||
error: Error | null = null;
|
||||
|
||||
constructor(private readonly store: FakeObjectStore) {}
|
||||
|
||||
objectStore(): FakeObjectStore {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
addEventListener(): void {}
|
||||
}
|
||||
|
||||
class FakeDatabase {
|
||||
readonly objectStoreNames = {
|
||||
contains: () => true,
|
||||
};
|
||||
|
||||
constructor(private readonly store: FakeObjectStore) {}
|
||||
|
||||
createObjectStore(): void {}
|
||||
|
||||
transaction(): FakeTransaction {
|
||||
return new FakeTransaction(this.store);
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
describe("indexeddb attachment store", () => {
|
||||
let storedRecord: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
storedRecord = null;
|
||||
Object.defineProperty(globalThis, "indexedDB", {
|
||||
configurable: true,
|
||||
value: {
|
||||
open: () => {
|
||||
const store = new FakeObjectStore((record) => {
|
||||
storedRecord = record;
|
||||
});
|
||||
const request = new FakeRequest<FakeDatabase>();
|
||||
request.result = new FakeDatabase(store);
|
||||
queueMicrotask(() => request.emit("success"));
|
||||
return request;
|
||||
},
|
||||
} as unknown as IDBFactory,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "indexedDB");
|
||||
});
|
||||
|
||||
it("stores raw byte sources as a Blob", async () => {
|
||||
const store = createIndexedDbAttachmentStore();
|
||||
const bytes = new Uint8Array([0, 1, 2, 3]);
|
||||
|
||||
const attachment = await store.save({
|
||||
id: "att_bytes",
|
||||
mimeType: "image/png",
|
||||
fileName: "image.png",
|
||||
source: { kind: "bytes", bytes },
|
||||
});
|
||||
|
||||
expect(storedRecord).toEqual({
|
||||
id: "att_bytes",
|
||||
blob: new Blob([bytes], { type: "image/png" }),
|
||||
createdAt: expect.any(Number),
|
||||
fileName: "image.png",
|
||||
});
|
||||
expect(attachment).toMatchObject({
|
||||
id: "att_bytes",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "att_bytes",
|
||||
fileName: "image.png",
|
||||
byteSize: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function runTx<T>(
|
||||
const request = run(store);
|
||||
|
||||
request.addEventListener("success", () => {
|
||||
resolve(request.result as T);
|
||||
resolve(request.result);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
@@ -74,17 +74,18 @@ function runTx<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
|
||||
const binary = atob(input.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes], { type: input.mimeType });
|
||||
}
|
||||
|
||||
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||
const source = input.source;
|
||||
if (source.kind === "bytes") {
|
||||
const mimeType = normalizeMimeType(input.mimeType);
|
||||
const buffer = new ArrayBuffer(source.bytes.byteLength);
|
||||
new Uint8Array(buffer).set(source.bytes);
|
||||
return {
|
||||
blob: new Blob([buffer], { type: mimeType }),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === "blob") {
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
||||
const blob =
|
||||
@@ -105,14 +106,6 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === "base64") {
|
||||
const mimeType = normalizeMimeType(input.mimeType);
|
||||
return {
|
||||
blob: base64ToBlob({ base64: source.base64, mimeType }),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(source.uri);
|
||||
const blob = await response.blob();
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||
|
||||
@@ -196,7 +196,7 @@ export function AdaptiveModalSheet({
|
||||
() => ({ backgroundColor: theme.colors.surface2 }),
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
onClose,
|
||||
@@ -228,6 +228,7 @@ export function AdaptiveModalSheet({
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
|
||||
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider as AgentProvider,
|
||||
provider: provider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
|
||||
@@ -19,8 +19,12 @@ const mockSessionState = vi.hoisted(() => ({
|
||||
client: null,
|
||||
agentStreamHead: new Map<string, StreamItem[]>(),
|
||||
workspaces: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
agentTimelineHasOlder: new Map(),
|
||||
agentTimelineOlderFetchInFlight: new Map(),
|
||||
},
|
||||
},
|
||||
setAgentTimelineOlderFetchInFlight: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
|
||||
@@ -56,6 +56,8 @@ import type {
|
||||
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";
|
||||
@@ -145,6 +147,7 @@ export interface AgentStreamViewProps {
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
@@ -158,6 +161,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
},
|
||||
ref,
|
||||
@@ -198,6 +202,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workspaceRoot,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||
filePath: string;
|
||||
}) {
|
||||
@@ -796,6 +805,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
browserId: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
isInteractive?: boolean;
|
||||
onFocusPane?: () => void;
|
||||
}
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const subtitleStyle = useMemo(
|
||||
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
browserId: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
isInteractive?: boolean;
|
||||
onFocusPane?: () => void;
|
||||
}
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const subtitleStyle = useMemo(
|
||||
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>
|
||||
Open this workspace in Electron to use the built-in browser.
|
||||
</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
}));
|
||||
@@ -63,8 +63,8 @@ describe("combined model selector helpers", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
|
||||
@@ -622,7 +622,7 @@ export function CombinedModelSelector({
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
const providers = Array.from(allProviderModels.keys());
|
||||
if (providers.length !== 1) return null;
|
||||
const providerId = providers[0]!;
|
||||
const providerId = providers[0];
|
||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [allProviderModels, providerDefinitions]);
|
||||
@@ -655,7 +655,7 @@ export function CombinedModelSelector({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
onSelect(provider, modelId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
lineHeight: { diff: 18 },
|
||||
opacity: { 50: 0.5 },
|
||||
shadow: { md: {} },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
@@ -225,7 +226,9 @@ vi.mock("lucide-react-native", () => {
|
||||
Pencil: createIcon("Pencil"),
|
||||
AudioLines: createIcon("AudioLines"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
MousePointer2: createIcon("MousePointer2"),
|
||||
GitPullRequest: createIcon("GitPullRequest"),
|
||||
MessageSquareCode: createIcon("MessageSquareCode"),
|
||||
X: createIcon("X"),
|
||||
Mic: createIcon("Mic"),
|
||||
MicOff: createIcon("MicOff"),
|
||||
@@ -574,6 +577,7 @@ let workspaceBindingRenderCount = 0;
|
||||
|
||||
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
|
||||
type BrowserElementComposerAttachment = Extract<ComposerAttachment, { kind: "browser_element" }>;
|
||||
|
||||
function reviewAttachment(body: string): ReviewAttachment {
|
||||
return {
|
||||
@@ -619,6 +623,30 @@ function reviewComposerAttachment(body: string): ReviewComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementComposerAttachment(): BrowserElementComposerAttachment {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com/page",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Save",
|
||||
outerHTML: '<button class="primary">Save</button>',
|
||||
computedStyles: { display: "flex" },
|
||||
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||
reactSource: {
|
||||
fileName: "src/save-button.tsx",
|
||||
lineNumber: 12,
|
||||
columnNumber: 3,
|
||||
componentName: "SaveButton",
|
||||
},
|
||||
parentChain: ["form.settings"],
|
||||
children: [],
|
||||
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneReviewComposerAttachment(
|
||||
attachment: ReviewComposerAttachment,
|
||||
): ReviewComposerAttachment {
|
||||
@@ -1043,6 +1071,21 @@ describe("Composer attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes browser element workspace attachments as generic text attachments", async () => {
|
||||
const browserElement = browserElementComposerAttachment();
|
||||
expect(splitComposerAttachmentsForSubmit([browserElement])).toEqual({
|
||||
images: [],
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Browser element · button",
|
||||
text: browserElement.attachment.formatted,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => {
|
||||
const review = reviewComposerAttachment("Stable workspace review.");
|
||||
|
||||
|
||||
@@ -160,20 +160,17 @@ function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index:
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled =
|
||||
!isConnected || isCancellingAgent ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.cancelButton as object, disabled].filter((value): value is object =>
|
||||
Boolean(value),
|
||||
);
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
}
|
||||
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? (styles.iconButtonHovered as object) : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.realtimeVoiceButton as object, hoveredStyle, disabledStyle].filter(
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
@@ -853,9 +850,7 @@ function ComposerCancelButton({
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
);
|
||||
const shortcutNode = agentInterruptKeys ? (
|
||||
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
||||
) : null;
|
||||
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
@@ -941,9 +936,7 @@ function ComposerVoiceModeButton({
|
||||
realtimeVoiceButtonStyle,
|
||||
voiceToggleKeys,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null;
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (isVoiceSwitching) {
|
||||
@@ -1874,10 +1867,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
@@ -138,7 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -132,7 +132,7 @@ function TreeRowItem({
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void onCopyPath(entry.path);
|
||||
onCopyPath(entry.path);
|
||||
}, [onCopyPath, entry.path]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@server/client/daemon-client";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -27,8 +28,9 @@ import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBase64 } from "@/attachments/service";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -65,30 +67,34 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
|
||||
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}> {
|
||||
if (!file || file.kind !== "image" || !file.content) {
|
||||
return { file, imageAttachment: null };
|
||||
if (!file) {
|
||||
return { file: null, imageAttachment: null };
|
||||
}
|
||||
|
||||
const { content: _content, ...imageFile } = file;
|
||||
const imageAttachment = await persistAttachmentFromBase64({
|
||||
const explorerFile = explorerFileFromReadResult(file);
|
||||
if (file.kind !== "image") {
|
||||
return { file: explorerFile, imageAttachment: null };
|
||||
}
|
||||
|
||||
const imageAttachment = await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: file.mimeType ?? "image/png",
|
||||
mimeType: file.mime,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.content.length,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType,
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path),
|
||||
});
|
||||
|
||||
return {
|
||||
file: imageFile,
|
||||
file: explorerFile,
|
||||
imageAttachment,
|
||||
};
|
||||
}
|
||||
@@ -358,17 +364,21 @@ export function FilePane({
|
||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
}
|
||||
const payload = await client.exploreFileSystem(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedFilePath,
|
||||
"file",
|
||||
);
|
||||
const preview = await createFilePanePreview(payload.file ?? null);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: payload.error ?? null,
|
||||
};
|
||||
try {
|
||||
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
|
||||
const preview = await createFilePanePreview(file);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : "Failed to load file",
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
memo,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -230,6 +231,64 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
||||
|
||||
function noopStartComment(): void {}
|
||||
|
||||
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
||||
|
||||
function LongPressableLine({
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions: InlineReviewActions | undefined;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
style: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const onStartComment = reviewActions?.onStartComment;
|
||||
const handlePress = useCallback(() => {
|
||||
if (reviewTarget && onStartComment) {
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
}, [reviewTarget, onStartComment]);
|
||||
|
||||
const handleHoverIn = useCallback(() => {
|
||||
onHoverChange?.(true);
|
||||
if (hoverTargetKey) {
|
||||
onHoverTargetChange?.(hoverTargetKey);
|
||||
}
|
||||
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
||||
const handleHoverOut = useCallback(() => {
|
||||
onHoverChange?.(false);
|
||||
if (hoverTargetKey) {
|
||||
onHoverTargetChange?.(null);
|
||||
}
|
||||
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
||||
const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]);
|
||||
|
||||
if (isWeb && (onHoverChange || onHoverTargetChange)) {
|
||||
return (
|
||||
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNative || !reviewTarget || !onStartComment) {
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
|
||||
if (!type) return styles.emptySplitCell;
|
||||
if (type === "add") return styles.addLineContainer;
|
||||
@@ -244,6 +303,7 @@ function DiffGutterCell({
|
||||
gutterWidth,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
isLineHovered,
|
||||
style,
|
||||
}: {
|
||||
lineNumber: number | null;
|
||||
@@ -251,6 +311,7 @@ function DiffGutterCell({
|
||||
gutterWidth: number;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
isLineHovered?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const containerStyle = useMemo(
|
||||
@@ -280,7 +341,7 @@ function DiffGutterCell({
|
||||
reviewTarget={reviewTarget}
|
||||
comments={comments}
|
||||
isEditorOpen={isEditorOpen}
|
||||
showPersistentAction={reviewActions?.showPersistentAction ?? false}
|
||||
isLineHovered={isLineHovered}
|
||||
onStartComment={onStartComment}
|
||||
style={containerStyle}
|
||||
>
|
||||
@@ -291,7 +352,23 @@ function DiffGutterCell({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean }) {
|
||||
function DiffTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -311,22 +388,37 @@ function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<LongPressableLine
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={onHoverChange}
|
||||
hoverTargetKey={hoverTargetKey}
|
||||
onHoverTargetChange={onHoverTargetChange}
|
||||
style={containerStyle}
|
||||
>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</LongPressableLine>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
wrapLines: boolean;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
@@ -347,13 +439,20 @@ function SplitTextLine({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<LongPressableLine
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={onHoverChange}
|
||||
hoverTargetKey={hoverTargetKey}
|
||||
onHoverTargetChange={onHoverTargetChange}
|
||||
style={containerStyle}
|
||||
>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</LongPressableLine>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,6 +471,7 @@ function DiffLineView({
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -391,13 +491,19 @@ function DiffLineView({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<LongPressableLine
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={setIsLineHovered}
|
||||
style={containerStyle}
|
||||
>
|
||||
<DiffGutterCell
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
@@ -405,7 +511,7 @@ function DiffLineView({
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</LongPressableLine>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -420,6 +526,7 @@ function SplitDiffLine({
|
||||
wrapLines: boolean;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -439,13 +546,19 @@ function SplitDiffLine({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<LongPressableLine
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={setIsLineHovered}
|
||||
style={containerStyle}
|
||||
>
|
||||
<DiffGutterCell
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{visibleTokens ? (
|
||||
@@ -453,7 +566,7 @@ function SplitDiffLine({
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</LongPressableLine>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -573,6 +686,7 @@ function SplitDiffColumn({
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||
|
||||
const wrapCellStyle = useMemo(
|
||||
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
||||
@@ -639,6 +753,7 @@ function SplitDiffColumn({
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||
const reviewRowState = getSplitInlineReviewThreadState({
|
||||
left: row.left?.reviewTarget,
|
||||
right: row.right?.reviewTarget,
|
||||
@@ -652,6 +767,9 @@ function SplitDiffColumn({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
|
||||
}
|
||||
/>
|
||||
<InlineReviewGutterSpacer
|
||||
reviewTarget={line?.reviewTarget}
|
||||
@@ -679,6 +797,7 @@ function SplitDiffColumn({
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||
const reviewRowState = getSplitInlineReviewThreadState({
|
||||
left: row.left?.reviewTarget,
|
||||
right: row.right?.reviewTarget,
|
||||
@@ -686,7 +805,13 @@ function SplitDiffColumn({
|
||||
});
|
||||
return (
|
||||
<View key={key}>
|
||||
<SplitTextLine line={line} wrapLines={false} />
|
||||
<SplitTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTargetKey}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
/>
|
||||
<InlineReviewThreadContent
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
@@ -810,6 +935,7 @@ function DiffFileBody({
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [bodyWidth, setBodyWidth] = useState(0);
|
||||
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleLayout = useCallback(
|
||||
@@ -913,6 +1039,9 @@ function DiffFileBody({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
|
||||
}
|
||||
/>
|
||||
<InlineReviewGutterSpacer
|
||||
reviewTarget={reviewTarget}
|
||||
@@ -931,7 +1060,14 @@ function DiffFileBody({
|
||||
<View style={linesContainerRowStyle}>
|
||||
{computedLines.map(({ line, key, reviewTarget }) => (
|
||||
<View key={key}>
|
||||
<DiffTextLine line={line} wrapLines={false} />
|
||||
<DiffTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTarget?.key ?? null}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
/>
|
||||
<InlineReviewThreadContent
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
@@ -1872,7 +2008,6 @@ export function GitDiffPane({
|
||||
|
||||
const reviewActions = useInlineReviewController({
|
||||
reviewDraftKey,
|
||||
showPersistentAction: isMobile,
|
||||
});
|
||||
const reviewAttachment = useReviewAttachmentSnapshot({
|
||||
key: reviewDraftKey,
|
||||
@@ -2932,11 +3067,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
gutterColumn: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
gutterCell: {
|
||||
borderRightWidth: theme.borderWidth[1],
|
||||
borderRightColor: theme.colors.border,
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
inlineReviewRow: {
|
||||
flexDirection: "row",
|
||||
@@ -2984,6 +3125,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffLineContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
overflow: "visible",
|
||||
},
|
||||
lineNumberGutter: {
|
||||
borderRightWidth: theme.borderWidth[1],
|
||||
@@ -2991,8 +3133,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: theme.spacing[2],
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
lineNumberText: {
|
||||
width: "100%",
|
||||
textAlign: "right",
|
||||
paddingRight: theme.spacing[2],
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -88,8 +88,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
shortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
shortcut: {},
|
||||
}));
|
||||
|
||||
@@ -278,7 +278,7 @@ function VoiceTooltipBody({
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
|
||||
{shortcut ? <Shortcut chord={shortcut} style={styles.tooltipShortcut} /> : null}
|
||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -293,7 +293,7 @@ function SendTooltipBody({
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1328,7 +1328,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
@@ -1925,10 +1924,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
@@ -159,7 +159,9 @@ interface MarkdownWithStableRendererProps {
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
@@ -426,6 +428,10 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
return `PR #${attachment.number}`;
|
||||
case "github_issue":
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,7 +638,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
useEffect(() => {
|
||||
if (cachedMetadata) {
|
||||
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
setLoadState({ status: "loading" });
|
||||
@@ -760,25 +766,21 @@ function AssistantMarkdownImage({
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
const file = payload.file;
|
||||
if (!file || file.kind !== "image" || !file.content) {
|
||||
const file = await client.readFile(resolution.cwd, resolution.path);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error("Image preview unavailable.");
|
||||
}
|
||||
|
||||
return await persistAttachmentFromBase64({
|
||||
return await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: file.mimeType ?? "image/png",
|
||||
mimeType: file.mime,
|
||||
path: file.path || resolution.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.content.length,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType,
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||
});
|
||||
},
|
||||
@@ -926,11 +928,11 @@ function getInlineCodeAutoLinkUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = markdownParser.linkify.match(trimmed) as Array<{
|
||||
const matches: Array<{
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
url: string;
|
||||
}> | null;
|
||||
}> | null = markdownParser.linkify.match(trimmed);
|
||||
if (!matches || matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -952,7 +954,7 @@ function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
"type" in parent &&
|
||||
(parent as { type?: string }).type === type
|
||||
(parent as Record<"type", unknown>)["type"] === type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1640,16 +1642,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, {
|
||||
style: [
|
||||
(child.props as { style?: StyleProp<TextStyle> }).style,
|
||||
{ color: styles.link.color as string | undefined },
|
||||
],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>)
|
||||
: child,
|
||||
)}
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||||
return cloneElement(child, {
|
||||
style: [childProps.style, { color: styles.link.color }],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||
})}
|
||||
</MarkdownLink>
|
||||
),
|
||||
image: (
|
||||
@@ -1742,8 +1741,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
@@ -1771,7 +1770,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
return (
|
||||
<View testID="speak-message" style={containerStyle}>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
@@ -1812,9 +1811,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
successBg: {
|
||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||
},
|
||||
errorBg: {
|
||||
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
||||
},
|
||||
errorBg: {},
|
||||
artifactBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||
},
|
||||
@@ -1829,6 +1826,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
iconContainer: {
|
||||
flexShrink: 0,
|
||||
height: 20,
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
@@ -1938,7 +1937,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
<IconComponent size={16} color={config.color} />
|
||||
</View>
|
||||
<View style={activityLogStylesheet.textContainer}>
|
||||
<Text style={messageTextStyle}>{displayMessage}</Text>
|
||||
<Text style={messageTextStyle} selectable>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
@@ -2421,12 +2422,13 @@ function useDetailWheelPropagationBlocker(input: {
|
||||
const { detailWrapperRef, enabled } = input;
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
const rawRef: unknown = detailWrapperRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const node = rawRef;
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
@@ -2596,7 +2598,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
enabled: !isNative && isExpanded && hasDetailContent,
|
||||
});
|
||||
|
||||
const shimmerLabelStyle = useMemo(
|
||||
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2605,7 +2607,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: labelOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2616,7 +2618,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
],
|
||||
);
|
||||
|
||||
const shimmerSecondaryStyle = useMemo(
|
||||
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2625,7 +2627,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: secondaryOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2807,9 +2809,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
args?: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
@@ -2948,7 +2950,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
return () => {
|
||||
onInlineDetailsExpandedChange(false);
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PrPaneData } from "@/utils/pr-pane-data";
|
||||
import { PrPane } from "./pr-pane";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" },
|
||||
colors: {
|
||||
surfaceSidebar: "#0b0b0b",
|
||||
surfaceSidebarHover: "#151515",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
statusSuccess: "#30d158",
|
||||
statusDanger: "#ff453a",
|
||||
statusWarning: "#f2c945",
|
||||
statusMerged: "#a371f7",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
CircleCheck: createIcon("CircleCheck"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
CircleSlash: createIcon("CircleSlash"),
|
||||
CircleX: createIcon("CircleX"),
|
||||
ExternalLink: createIcon("ExternalLink"),
|
||||
GitMerge: createIcon("GitMerge"),
|
||||
GitPullRequest: createIcon("GitPullRequest"),
|
||||
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
|
||||
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
|
||||
MessageSquare: createIcon("MessageSquare"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 1284,
|
||||
title: "fix(server,app): coalesce tool call stream events",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
||||
reviewDecision: "changes_requested",
|
||||
awaitingReviewers: [],
|
||||
checks: [
|
||||
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
|
||||
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
kind: "review",
|
||||
author: "alicek",
|
||||
avatarColor: "#8b5cf6",
|
||||
reviewState: "approved",
|
||||
body: "LGTM",
|
||||
age: "2h ago",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
kind: "comment",
|
||||
author: "bmartin",
|
||||
avatarColor: "#f97316",
|
||||
body: "Worth a benchmark before/after.",
|
||||
age: "3h ago",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(data: PrPaneData) {
|
||||
act(() => {
|
||||
root?.render(<PrPane data={data} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("PrPane", () => {
|
||||
it("renders the PR title and 'Open' state label from the data prop", () => {
|
||||
render(makeData());
|
||||
|
||||
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
|
||||
expect(container?.textContent).toContain("Open");
|
||||
});
|
||||
|
||||
it("renders the correct state label and icon for each PR state", () => {
|
||||
render(makeData({ state: "draft" }));
|
||||
expect(container?.textContent).toContain("Draft");
|
||||
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
|
||||
|
||||
render(makeData({ state: "merged" }));
|
||||
expect(container?.textContent).toContain("Merged");
|
||||
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
|
||||
|
||||
render(makeData({ state: "closed" }));
|
||||
expect(container?.textContent).toContain("Closed");
|
||||
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("derives check summary counts from the data prop", () => {
|
||||
render(makeData());
|
||||
const text = container?.textContent ?? "";
|
||||
|
||||
expect(text).toContain("typecheck");
|
||||
expect(text).toContain("test");
|
||||
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
|
||||
expect(text).toMatch(/1.*1/);
|
||||
});
|
||||
|
||||
it("derives review summary counts and renders activity rows", () => {
|
||||
render(makeData());
|
||||
const text = container?.textContent ?? "";
|
||||
|
||||
expect(text).toContain("alicek");
|
||||
expect(text).toContain("Approved");
|
||||
expect(text).toContain("bmartin");
|
||||
expect(text).toContain("Commented");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MessageSquare,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||
import type {
|
||||
CheckStatus,
|
||||
PrPaneActivity,
|
||||
@@ -283,13 +284,6 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityVerb(item: PrPaneActivity): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
}
|
||||
|
||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
||||
return GitPullRequest;
|
||||
}
|
||||
|
||||
function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface PathRowProps {
|
||||
function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
void onSelect(path);
|
||||
onSelect(path);
|
||||
}, [onSelect, path]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -172,7 +172,7 @@ export function ProjectPickerModal() {
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options.length > 0 && activeIndex < options.length) {
|
||||
void handleSelectPath(options[activeIndex]!);
|
||||
void handleSelectPath(options[activeIndex]);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitCustom();
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ function CustomModelsSection(props: {
|
||||
},
|
||||
},
|
||||
});
|
||||
await refresh([provider as AgentProvider]);
|
||||
await refresh([provider]);
|
||||
},
|
||||
[patchConfig, provider, refresh],
|
||||
);
|
||||
@@ -284,7 +284,7 @@ export function ProviderDiagnosticSheet({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
@@ -308,7 +308,7 @@ export function ProviderDiagnosticSheet({
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||
void Promise.all([refresh([provider]), fetchDiagnostic()]).catch((err) => {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
@@ -37,9 +37,9 @@ import {
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("sidebar workspace render isolation", () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
...createWorkspaces()[1],
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -104,9 +104,14 @@ import {
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -404,7 +439,7 @@ function WorkspaceStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
|
||||
let KindIcon: typeof ThemedMonitor | null;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = null;
|
||||
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
|
||||
toast.error(message);
|
||||
});
|
||||
})();
|
||||
}, [
|
||||
archiveWorktree,
|
||||
isArchiving,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.workspaceDirectory,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2120,13 +2144,28 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
@@ -2724,10 +2763,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
projectActionTooltipShortcut: {},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -91,6 +91,8 @@ interface SplitContainerProps {
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCreateDraftTab: (input: { paneId?: string }) => void;
|
||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||
onCreateBrowserTab: (input: { paneId?: string }) => void;
|
||||
showCreateBrowserTab?: boolean;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -123,10 +125,30 @@ interface SplitPaneDropData {
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
@@ -158,6 +180,7 @@ interface MountedTabSlotProps {
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
paneId: string;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -170,6 +193,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
paneId,
|
||||
onFocusPane,
|
||||
buildPaneContentModel,
|
||||
}: MountedTabSlotProps) {
|
||||
const content = useMemo(
|
||||
@@ -181,10 +205,13 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const wrapperStyle = useMemo(
|
||||
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
|
||||
[isVisible],
|
||||
);
|
||||
const wrapperStyle = useMemo(() => {
|
||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||
return { display, flex: 1 };
|
||||
}, [isVisible]);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
@@ -192,6 +219,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -339,6 +367,8 @@ export function SplitContainer({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -370,7 +400,7 @@ export function SplitContainer({
|
||||
if (!focusModeEnabled) {
|
||||
return layout.root;
|
||||
}
|
||||
const focusedPane = panesById.get(layout.focusedPaneId);
|
||||
const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null;
|
||||
if (!focusedPane) {
|
||||
return layout.root;
|
||||
}
|
||||
@@ -378,8 +408,8 @@ export function SplitContainer({
|
||||
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
if (data?.kind !== "workspace-tab") {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
@@ -396,11 +426,8 @@ export function SplitContainer({
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
@@ -503,11 +530,8 @@ export function SplitContainer({
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
@@ -557,6 +581,8 @@ export function SplitContainer({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -694,6 +720,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -744,6 +772,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -787,6 +817,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -836,6 +868,8 @@ function SplitPaneView({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane: _onSplitPane,
|
||||
@@ -885,17 +919,14 @@ function SplitPaneView({
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
const rawRef: unknown = paneRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const paneElement = rawRef;
|
||||
|
||||
const handlePanePointerDown = (event: PointerEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
@@ -977,6 +1008,8 @@ function SplitPaneView({
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
@@ -1004,6 +1037,7 @@ function SplitPaneView({
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
onFocusPane={stableOnFocusPane}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
@@ -29,6 +31,7 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -45,6 +48,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
@@ -65,6 +71,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
|
||||
const historyRows = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
@@ -155,6 +162,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollOffsetYRef.current = 0;
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -227,6 +241,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
onNearBottomChange(nearBottom);
|
||||
|
||||
const distanceFromOldestEdge =
|
||||
streamViewportMetricsRef.current.contentHeight -
|
||||
streamViewportMetricsRef.current.viewportHeight -
|
||||
contentOffset.y;
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
|
||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
@@ -307,6 +333,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
);
|
||||
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
|
||||
|
||||
const historyFooterContent = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
@@ -316,6 +353,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
ListFooterComponent={historyFooterContent ?? undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
|
||||
@@ -133,6 +133,9 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -145,4 +148,63 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||
});
|
||||
|
||||
it("fires near-history-start when the user scrolls near the top", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<>
|
||||
{strategy.render({
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
expect(scrollContainer).toBeInstanceOf(HTMLElement);
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
|
||||
act(() => {
|
||||
scrollContainer?.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} 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";
|
||||
@@ -23,8 +24,18 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
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";
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 32,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
function isScrollContainerNearBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
@@ -91,6 +102,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
@@ -115,6 +129,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -280,7 +295,24 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
updateScrollMetrics();
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics]);
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
currentScrollTop <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
};
|
||||
}, [props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -501,6 +533,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
const historyStartSlot = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
</div>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -516,6 +558,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface StreamRenderInput {
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
onNearHistoryStart: () => void;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
scrollEnabled: boolean;
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -18,6 +19,7 @@ import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
@@ -127,6 +129,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -140,6 +143,16 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isTerminalState(value: unknown): value is TerminalState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"rows" in value &&
|
||||
"cols" in value &&
|
||||
"grid" in value
|
||||
);
|
||||
}
|
||||
|
||||
function ensureTerminalScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
@@ -181,6 +194,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -198,6 +212,8 @@ export default function TerminalEmulator({
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const xtermThemeRef = useRef(xtermTheme);
|
||||
xtermThemeRef.current = xtermTheme;
|
||||
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||
const mountCallbacksRef = useRef({
|
||||
onInput,
|
||||
onResize,
|
||||
@@ -224,20 +240,41 @@ export default function TerminalEmulator({
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
useDOMImperativeHandle(
|
||||
ref as Ref<DOMImperativeFactory>,
|
||||
() =>
|
||||
({
|
||||
writeOutput: (text: string) => {
|
||||
runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
domBridgeRef,
|
||||
(): DOMImperativeFactory => ({
|
||||
writeOutput: (...args) => {
|
||||
const text = args[0];
|
||||
if (typeof text === "string") runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (...args) => {
|
||||
const state = args[0];
|
||||
if (state === null) {
|
||||
runtimeRef.current?.renderSnapshot({ state: null });
|
||||
} else if (isTerminalState(state)) {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}) as unknown as DOMImperativeFactory,
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
writeOutput: (text: string) => {
|
||||
runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -254,7 +291,7 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const SWIPE_MIN_PX = 22;
|
||||
@@ -371,7 +408,7 @@ export default function TerminalEmulator({
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
@@ -389,9 +426,11 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
return () => {
|
||||
runtime.unmount();
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
@@ -416,7 +455,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
return focusWithRetries({
|
||||
@@ -444,14 +483,14 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
@@ -559,7 +598,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -164,6 +170,7 @@ export function TerminalPane({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
// Keep the latest measured size for whichever client currently owns the pane,
|
||||
// but only dedupe resizes that this specific client has already pushed.
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
@@ -175,6 +182,7 @@ export function TerminalPane({
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
@@ -195,6 +203,20 @@ export function TerminalPane({
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const handleRendererReadyChange = useCallback(
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
@@ -617,6 +639,13 @@ export function TerminalPane({
|
||||
if (!swipeGesturesEnabled) return;
|
||||
onOpenFileExplorer();
|
||||
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
||||
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused,
|
||||
hasStreamError: Boolean(streamError),
|
||||
isAttaching,
|
||||
rendererReadyStreamKey,
|
||||
terminalStreamKey,
|
||||
});
|
||||
|
||||
if (!client || !isConnected) {
|
||||
return (
|
||||
@@ -634,11 +663,12 @@ export function TerminalPane({
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
streamKey={terminalStreamKey}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onInput={handleTerminalData}
|
||||
@@ -654,7 +684,7 @@ export function TerminalPane({
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isWorkspaceFocused ? (
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ToastVariant = "default" | "success" | "error";
|
||||
export interface ToastShowOptions {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
durationMs?: number | null;
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface ToastState {
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
durationMs: number | null;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ export function useToastHost(): {
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
const duration =
|
||||
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
|
||||
return;
|
||||
}
|
||||
@@ -148,8 +149,13 @@ export function ToastViewport({
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(durationMs: number) => {
|
||||
(durationMs: number | null) => {
|
||||
clearTimer();
|
||||
if (durationMs === null) {
|
||||
remainingDurationRef.current = 0;
|
||||
dismissDeadlineRef.current = null;
|
||||
return;
|
||||
}
|
||||
const nextDurationMs = Math.max(0, durationMs);
|
||||
remainingDurationRef.current = nextDurationMs;
|
||||
dismissDeadlineRef.current = Date.now() + nextDurationMs;
|
||||
@@ -169,7 +175,7 @@ export function ToastViewport({
|
||||
}, [clearTimer]);
|
||||
|
||||
const resumeDismiss = useCallback(() => {
|
||||
if (!toast) {
|
||||
if (!toast || toast.durationMs === null) {
|
||||
return;
|
||||
}
|
||||
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
|
||||
@@ -204,7 +210,6 @@ export function ToastViewport({
|
||||
}),
|
||||
]).start();
|
||||
|
||||
remainingDurationRef.current = toast.durationMs;
|
||||
scheduleDismiss(toast.durationMs);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -244,15 +244,125 @@ function resolveSubAgentFallbackHeader(
|
||||
|
||||
interface SubAgentDetailProps {
|
||||
log: string;
|
||||
childSessionId: string | null | undefined;
|
||||
subAgentType: string | null | undefined;
|
||||
description: string | null | undefined;
|
||||
ds: DetailStyles;
|
||||
}
|
||||
|
||||
function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentDetailProps) {
|
||||
const activityLog = log.replace(/^\n+/, "");
|
||||
const hasLog = activityLog.length > 0;
|
||||
interface SubAgentActivityRow {
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface ParsedSubAgentLog {
|
||||
actions: SubAgentActivityRow[];
|
||||
remainingLog: string;
|
||||
}
|
||||
|
||||
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
|
||||
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const toolName = match[1]?.trim();
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const summary = match[2]?.trim();
|
||||
return {
|
||||
index,
|
||||
toolName,
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubAgentLog(log: string): ParsedSubAgentLog {
|
||||
const actions: SubAgentActivityRow[] = [];
|
||||
const remainingLines: string[] = [];
|
||||
for (const line of log.replace(/^\n+/, "").split("\n")) {
|
||||
const normalizedLine = line.trim();
|
||||
if (!normalizedLine) {
|
||||
continue;
|
||||
}
|
||||
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
|
||||
if (parsedAction) {
|
||||
actions.push(parsedAction);
|
||||
} else {
|
||||
remainingLines.push(line);
|
||||
}
|
||||
}
|
||||
return {
|
||||
actions,
|
||||
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
|
||||
return (
|
||||
<View style={styles.subAgentActionRow}>
|
||||
<Text selectable style={styles.subAgentActionTool}>
|
||||
{formatSubAgentToolName(action.toolName)}
|
||||
</Text>
|
||||
{action.summary ? (
|
||||
<Text selectable style={styles.subAgentActionSummary}>
|
||||
{action.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSubAgentToolName(toolName: string): string {
|
||||
const trimmed = toolName.trim();
|
||||
if (!trimmed) {
|
||||
return toolName;
|
||||
}
|
||||
return trimmed
|
||||
.replace(/[._-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function SubAgentLogText({
|
||||
activityLog,
|
||||
fallbackHeader,
|
||||
hasActions,
|
||||
}: {
|
||||
activityLog: string;
|
||||
fallbackHeader: string;
|
||||
hasActions: boolean;
|
||||
}) {
|
||||
if (activityLog.length > 0) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{activityLog}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (!hasActions) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{fallbackHeader}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SubAgentDetailSection({
|
||||
log,
|
||||
childSessionId,
|
||||
subAgentType,
|
||||
description,
|
||||
ds,
|
||||
}: SubAgentDetailProps) {
|
||||
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
|
||||
const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description);
|
||||
const hasActions = actions.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
@@ -270,9 +380,23 @@ function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentD
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? activityLog : fallbackHeader}
|
||||
</Text>
|
||||
{childSessionId ? (
|
||||
<Text selectable style={styles.subAgentSessionText}>
|
||||
session {childSessionId}
|
||||
</Text>
|
||||
) : null}
|
||||
{hasActions ? (
|
||||
<View style={styles.subAgentActions}>
|
||||
{actions.map((action) => (
|
||||
<SubAgentActionRow key={action.index} action={action} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<SubAgentLogText
|
||||
activityLog={remainingLog}
|
||||
fallbackHeader={fallbackHeader}
|
||||
hasActions={hasActions}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
@@ -530,6 +654,7 @@ function buildDetailSections(
|
||||
<SubAgentDetailSection
|
||||
key="sub-agent"
|
||||
log={detail.log}
|
||||
childSessionId={detail.childSessionId}
|
||||
subAgentType={detail.subAgentType}
|
||||
description={detail.description}
|
||||
ds={ds}
|
||||
@@ -731,6 +856,34 @@ const styles = StyleSheet.create((theme) => {
|
||||
shellPrompt: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
subAgentSessionText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActions: {
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
subAgentActionTool: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
},
|
||||
subAgentActionSummary: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
jsonScroll: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useCallback, useMemo, useRef, ReactNode } from "react";
|
||||
import React, { createContext, useContext, useCallback, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
@@ -11,7 +12,7 @@ import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type IsolatedBottomSheetModalRef,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -63,32 +64,33 @@ interface ToolCallSheetProviderProps {
|
||||
|
||||
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const hasPresentedRef = useRef(false);
|
||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "95%"], []);
|
||||
|
||||
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
||||
setSheetData(data);
|
||||
if (hasPresentedRef.current) {
|
||||
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
|
||||
return;
|
||||
}
|
||||
|
||||
hasPresentedRef.current = true;
|
||||
bottomSheetRef.current?.present();
|
||||
setIsSheetOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeToolCall = useCallback(() => {
|
||||
bottomSheetRef.current?.close();
|
||||
setIsSheetOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSheetChange = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
setSheetData(null);
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: isSheetOpen,
|
||||
onClose: closeToolCall,
|
||||
});
|
||||
|
||||
const handleToolCallSheetDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
setSheetData(null);
|
||||
}, [handleSheetDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -116,6 +118,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleToolCallSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={CustomSheetBackground}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
default as React,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
@@ -17,7 +19,7 @@ import type {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
type LeftIcon =
|
||||
| ReactElement
|
||||
@@ -25,7 +27,7 @@ type LeftIcon =
|
||||
| ((color: string) => ReactElement)
|
||||
| null;
|
||||
|
||||
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
|
||||
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
base: {
|
||||
@@ -41,6 +43,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
xs: {
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
sm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -82,6 +90,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
textXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
textDefault: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
@@ -101,6 +112,7 @@ export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
leftIcon,
|
||||
trailing,
|
||||
style,
|
||||
textStyle,
|
||||
disabled,
|
||||
@@ -112,6 +124,7 @@ export function Button({
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
leftIcon?: LeftIcon;
|
||||
trailing?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
loading?: boolean;
|
||||
@@ -135,7 +148,9 @@ export function Button({
|
||||
}
|
||||
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "sm") {
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "sm") {
|
||||
sizeStyle = styles.sm;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
@@ -162,13 +177,14 @@ export function Button({
|
||||
const resolvedTextStyle = useMemo(
|
||||
() => [
|
||||
styles.text,
|
||||
size === "xs" ? styles.textXs : null,
|
||||
variant === "default" ? styles.textDefault : null,
|
||||
variant === "destructive" ? styles.textDestructive : null,
|
||||
variant === "ghost" ? styles.textGhost : null,
|
||||
textStyle,
|
||||
isGhostHovered ? styles.textGhostHovered : null,
|
||||
],
|
||||
[variant, textStyle, isGhostHovered],
|
||||
[size, variant, textStyle, isGhostHovered],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
@@ -235,6 +251,7 @@ export function Button({
|
||||
>
|
||||
{renderIcon()}
|
||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||
{trailing}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,8 +228,7 @@ export function ComboboxItem({
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.comboboxItem,
|
||||
Boolean(hovered) &&
|
||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
@@ -488,7 +487,7 @@ function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
|
||||
if (input.orderedVisibleOptions.length === 0) return;
|
||||
const { activeIndex, orderedVisibleOptions } = input;
|
||||
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||
input.handleSelect(orderedVisibleOptions[index]!.id);
|
||||
input.handleSelect(orderedVisibleOptions[index].id);
|
||||
}
|
||||
|
||||
interface FloatingSizeSetters {
|
||||
@@ -910,10 +909,13 @@ function resolveInitialActiveIndex(
|
||||
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
||||
}
|
||||
|
||||
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
||||
|
||||
interface MobileBodyProps {
|
||||
bottomSheetRef: ReturnType<typeof useIsolatedBottomSheetVisibility>["sheetRef"];
|
||||
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
||||
snapPoints: string[];
|
||||
handleSheetChange: ReturnType<typeof useIsolatedBottomSheetVisibility>["handleSheetChange"];
|
||||
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
||||
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
titleColor: string;
|
||||
title: string;
|
||||
@@ -971,6 +973,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={props.handleSheetChange}
|
||||
onDismiss={props.handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
@@ -1298,7 +1301,11 @@ export function Combobox({
|
||||
referenceWidth,
|
||||
});
|
||||
|
||||
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: isOpen,
|
||||
isEnabled: isMobile,
|
||||
onClose: handleClose,
|
||||
@@ -1454,6 +1461,7 @@ export function Combobox({
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
handleSheetChange={handleSheetChange}
|
||||
handleSheetDismiss={handleSheetDismiss}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={title}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -89,7 +88,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -175,25 +174,23 @@ function computePosition({
|
||||
return { x, y, actualPlacement };
|
||||
}
|
||||
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
return typeof fn === "function";
|
||||
}
|
||||
|
||||
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
||||
const wrapper = event as
|
||||
| {
|
||||
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
|
||||
const pageX = nativeEvent?.pageX;
|
||||
const pageY = nativeEvent?.pageY;
|
||||
if (typeof event !== "object" || event === null) {
|
||||
return null;
|
||||
}
|
||||
const nativeEvent = Reflect.get(event, "nativeEvent");
|
||||
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(native, "pageX");
|
||||
const pageY = Reflect.get(native, "pageY");
|
||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||
return { pageX, pageY };
|
||||
}
|
||||
const clientX = nativeEvent?.clientX;
|
||||
const clientY = nativeEvent?.clientY;
|
||||
const clientX = Reflect.get(native, "clientX");
|
||||
const clientY = Reflect.get(native, "clientY");
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
@@ -206,7 +203,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as MutableRefObject<T>).current = value;
|
||||
Object.assign(ref, { current: value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,10 +323,13 @@ export function ContextMenuTrigger({
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
openAtEvent(event as GestureResponderEvent);
|
||||
if (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||
}
|
||||
openAtEvent(event);
|
||||
},
|
||||
[openAtEvent],
|
||||
);
|
||||
@@ -337,7 +337,7 @@ export function ContextMenuTrigger({
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
return style({ pressed, hovered, open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
},
|
||||
@@ -400,7 +400,11 @@ export function ContextMenuContent({
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: useMobileSheet,
|
||||
onClose: handleClose,
|
||||
@@ -430,40 +434,29 @@ export function ContextMenuContent({
|
||||
|
||||
// Measure trigger when opening (fallback) and capture point anchors.
|
||||
useEffect(() => {
|
||||
if (useMobileSheet) {
|
||||
if (useMobileSheet || !open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (anchorRect) {
|
||||
setTriggerRect(anchorRect);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({
|
||||
...rect,
|
||||
y: rect.y + statusBarHeight,
|
||||
});
|
||||
return;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -534,6 +527,7 @@ export function ContextMenuContent({
|
||||
snapPoints={sheetSnapPoints}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderSheetBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={sheetBackgroundStyle}
|
||||
|
||||
@@ -62,6 +62,11 @@ interface DropdownMenuContextValue {
|
||||
|
||||
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
||||
|
||||
export function useDropdownMenuClose(): () => void {
|
||||
const { setOpen } = useDropdownMenuContext("useDropdownMenuClose");
|
||||
return useCallback(() => setOpen(false), [setOpen]);
|
||||
}
|
||||
|
||||
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
|
||||
const ctx = useContext(DropdownMenuContext);
|
||||
if (!ctx) {
|
||||
@@ -81,7 +86,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -743,12 +748,12 @@ export function DropdownMenuItem({
|
||||
return [
|
||||
styles.item,
|
||||
selectedStyle,
|
||||
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
|
||||
selected && (hovered || pressed) && selectedVariant !== "accent"
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -14,7 +14,12 @@ const SNAP_POINTS_90: (string | number)[] = ["90%"];
|
||||
|
||||
function noop(): void {}
|
||||
|
||||
const { modalMethods, modalProps } = vi.hoisted(() => ({
|
||||
interface CapturedModalProps {
|
||||
onChange?: (index: number) => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const { modalMethods, modalProps, shouldExposeModalRef } = vi.hoisted(() => ({
|
||||
modalMethods: {
|
||||
present: vi.fn(),
|
||||
close: vi.fn(),
|
||||
@@ -22,6 +27,7 @@ const { modalMethods, modalProps } = vi.hoisted(() => ({
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
modalProps: vi.fn(),
|
||||
shouldExposeModalRef: { current: true },
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
@@ -29,7 +35,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const MockBottomSheetModal = React.forwardRef(
|
||||
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
|
||||
modalProps(props);
|
||||
React.useImperativeHandle(ref, () => modalMethods);
|
||||
React.useImperativeHandle(ref, () => (shouldExposeModalRef.current ? modalMethods : null));
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "bottom-sheet" },
|
||||
@@ -54,7 +60,7 @@ vi.mock("@gorhom/portal", async () => {
|
||||
});
|
||||
|
||||
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
onClose,
|
||||
});
|
||||
@@ -65,26 +71,34 @@ function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }
|
||||
index={0}
|
||||
snapPoints={SNAP_POINTS_50}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
>
|
||||
<div>Sheet content</div>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
function latestModalProps(): CapturedModalProps {
|
||||
const props = modalProps.mock.lastCall?.[0];
|
||||
expect(props).toBeDefined();
|
||||
return props as CapturedModalProps;
|
||||
}
|
||||
|
||||
describe("IsolatedBottomSheetModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
shouldExposeModalRef.current = true;
|
||||
});
|
||||
|
||||
it("forces sheet isolation and keeps modal content mounted while hidden", () => {
|
||||
it("forces sheet isolation and uses the modal dismissal lifecycle", () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
|
||||
|
||||
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
|
||||
expect(modalProps).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
enableDismissOnClose: false,
|
||||
enableDismissOnClose: true,
|
||||
stackBehavior: "replace",
|
||||
}),
|
||||
);
|
||||
@@ -94,28 +108,61 @@ describe("IsolatedBottomSheetModal", () => {
|
||||
expect(modalMethods.present).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<Harness visible={false} onClose={onClose} />);
|
||||
expect(modalMethods.close).toHaveBeenCalledTimes(1);
|
||||
expect(modalMethods.dismiss).not.toHaveBeenCalled();
|
||||
expect(modalMethods.dismiss).toHaveBeenCalledTimes(1);
|
||||
expect(modalMethods.close).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<Harness visible onClose={onClose} />);
|
||||
expect(modalMethods.present).toHaveBeenCalledTimes(1);
|
||||
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0);
|
||||
expect(modalMethods.present).toHaveBeenCalledTimes(2);
|
||||
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only reports a user close when the sheet was visible", () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(<Harness visible onClose={onClose} />);
|
||||
|
||||
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
|
||||
latestProps.onChange(-1);
|
||||
latestModalProps().onChange?.(-1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<Harness visible={false} onClose={onClose} />);
|
||||
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
|
||||
closedProps.onChange(-1);
|
||||
latestModalProps().onChange?.(-1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a dismiss while visible as a close request", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Harness visible onClose={onClose} />);
|
||||
|
||||
latestModalProps().onDismiss?.();
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates close notifications from change and dismiss callbacks", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Harness visible onClose={onClose} />);
|
||||
|
||||
const props = latestModalProps();
|
||||
props.onChange?.(-1);
|
||||
props.onDismiss?.();
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("presents when the sheet ref becomes available after opening", () => {
|
||||
shouldExposeModalRef.current = false;
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(<Harness visible onClose={onClose} />);
|
||||
|
||||
expect(modalMethods.present).not.toHaveBeenCalled();
|
||||
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
|
||||
|
||||
shouldExposeModalRef.current = true;
|
||||
rerender(<Harness visible onClose={onClose} />);
|
||||
|
||||
expect(modalMethods.present).toHaveBeenCalledTimes(1);
|
||||
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
|
||||
const { getAllByTestId } = render(
|
||||
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
ref={ref}
|
||||
enableDismissOnClose={false}
|
||||
enableDismissOnClose
|
||||
stackBehavior={isNestedSheet ? "push" : "replace"}
|
||||
>
|
||||
{scopedChildren}
|
||||
@@ -68,39 +68,92 @@ export function useIsolatedBottomSheetVisibility({
|
||||
isEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const hasPresentedRef = useRef(false);
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef | null>(null);
|
||||
const visibleRef = useRef(visible);
|
||||
const isEnabledRef = useRef(isEnabled);
|
||||
const isPresentedRef = useRef(false);
|
||||
const hasNotifiedCloseRef = useRef(false);
|
||||
|
||||
visibleRef.current = visible;
|
||||
isEnabledRef.current = isEnabled;
|
||||
|
||||
const presentSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
|
||||
if (isPresentedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPresentedRef.current = true;
|
||||
hasNotifiedCloseRef.current = false;
|
||||
sheet.present();
|
||||
}, []);
|
||||
|
||||
const dismissSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
|
||||
if (!isPresentedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPresentedRef.current = false;
|
||||
sheet.dismiss();
|
||||
}, []);
|
||||
|
||||
const notifyClose = useCallback(() => {
|
||||
if (hasNotifiedCloseRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasNotifiedCloseRef.current = true;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleSheetDismiss = useCallback(() => {
|
||||
isPresentedRef.current = false;
|
||||
if (visibleRef.current) {
|
||||
notifyClose();
|
||||
return;
|
||||
}
|
||||
hasNotifiedCloseRef.current = false;
|
||||
}, [notifyClose]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1 && visibleRef.current) {
|
||||
notifyClose();
|
||||
}
|
||||
},
|
||||
[notifyClose],
|
||||
);
|
||||
|
||||
const setSheetRef = useCallback(
|
||||
(instance: IsolatedBottomSheetModalRef | null) => {
|
||||
sheetRef.current = instance;
|
||||
if (instance && visibleRef.current && isEnabledRef.current !== false) {
|
||||
presentSheet(instance);
|
||||
}
|
||||
},
|
||||
[presentSheet],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabled === false) return;
|
||||
|
||||
const sheet = sheetRef.current;
|
||||
if (visible) {
|
||||
if (hasPresentedRef.current) {
|
||||
sheetRef.current?.snapToIndex(0);
|
||||
if (!sheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasPresentedRef.current = true;
|
||||
sheetRef.current?.present();
|
||||
presentSheet(sheet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPresentedRef.current) {
|
||||
sheetRef.current?.close();
|
||||
if (sheet) {
|
||||
dismissSheet(sheet);
|
||||
}
|
||||
}, [isEnabled, visible]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1 && visible) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, visible],
|
||||
);
|
||||
}, [dismissSheet, isEnabled, presentSheet, visible]);
|
||||
|
||||
return {
|
||||
sheetRef,
|
||||
sheetRef: setSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
function colorWithOpacity(color: string, opacity: number): string {
|
||||
const hex = color.trim();
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
const r = Number.parseInt(hex[1] + hex[1], 16);
|
||||
const g = Number.parseInt(hex[2] + hex[2], 16);
|
||||
const b = Number.parseInt(hex[3] + hex[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16);
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16);
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
return `rgba(255, 255, 255, ${opacity})`;
|
||||
}
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
chord,
|
||||
@@ -53,9 +70,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
|
||||
borderWidth: 0,
|
||||
},
|
||||
sequence: {
|
||||
flexDirection: "row",
|
||||
@@ -66,6 +82,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
color: colorWithOpacity(theme.colors.foreground, 0.6),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -82,24 +81,20 @@ function shouldOpenOnFocus(): boolean {
|
||||
return !isWeb || lastInputWasKeyboard;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void,
|
||||
): (event: E) => void {
|
||||
return (event: E) => {
|
||||
original?.(event);
|
||||
injected?.(event);
|
||||
};
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
return typeof fn === "function";
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
function composeEventHandlers(
|
||||
original: unknown,
|
||||
injected: (event: unknown) => void,
|
||||
): (event: unknown) => void {
|
||||
return (event: unknown) => {
|
||||
if (isCallable(original)) {
|
||||
original(event);
|
||||
}
|
||||
injected(event);
|
||||
};
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
@@ -113,7 +108,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -320,7 +315,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverIn?.(e as never);
|
||||
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||
scheduleOpen();
|
||||
},
|
||||
[onHoverIn, scheduleOpen],
|
||||
@@ -328,7 +323,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverOut = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverOut?.(e as never);
|
||||
if (isCallable(onHoverOut)) onHoverOut(e);
|
||||
close();
|
||||
},
|
||||
[onHoverOut, close],
|
||||
@@ -336,7 +331,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: unknown) => {
|
||||
onFocus?.(e as never);
|
||||
if (isCallable(onFocus)) onFocus(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
if (!shouldOpenOnFocus()) return;
|
||||
clearOpenTimer();
|
||||
@@ -347,7 +342,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: unknown) => {
|
||||
onBlur?.(e as never);
|
||||
if (isCallable(onBlur)) onBlur(e);
|
||||
close();
|
||||
},
|
||||
[close, onBlur],
|
||||
@@ -355,7 +350,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handlePress = useCallback(
|
||||
(e: unknown) => {
|
||||
onPress?.(e as never);
|
||||
if (isCallable(onPress)) onPress(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -394,26 +389,33 @@ export function TooltipTrigger({
|
||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||
}
|
||||
|
||||
const childProps = child.props as Record<string, unknown>;
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
const rawProps: unknown = child.props;
|
||||
if (typeof rawProps !== "object" || rawProps === null) {
|
||||
throw new Error("TooltipTrigger asChild child must have props object");
|
||||
}
|
||||
const mergedProps: Record<string, unknown> = {
|
||||
...Object.assign({}, rawProps),
|
||||
...triggerProps,
|
||||
disabled: childProps.disabled || disabled,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
|
||||
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
|
||||
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
|
||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
|
||||
} as Record<string, unknown>;
|
||||
disabled: Reflect.get(rawProps, "disabled") || disabled,
|
||||
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
|
||||
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
|
||||
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
|
||||
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
|
||||
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
|
||||
};
|
||||
|
||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||
assignRef(existingRefProp, node);
|
||||
assignRef(ctx.triggerRef, node);
|
||||
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
|
||||
mergedProps[triggerRefProp] = (node: View) => {
|
||||
if (isCallable(existingRefProp)) {
|
||||
existingRefProp(node);
|
||||
} else if (existingRefProp && typeof existingRefProp === "object") {
|
||||
Object.assign(existingRefProp, { current: node });
|
||||
}
|
||||
Object.assign(ctx.triggerRef, { current: node });
|
||||
};
|
||||
|
||||
return cloneElement(child, mergedProps);
|
||||
@@ -453,16 +455,15 @@ export function TooltipContent({
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return;
|
||||
void measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -140,9 +140,9 @@ export function useWebScrollViewScrollbar(
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
if ("scrollToOffset" in scrollable) {
|
||||
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
||||
scrollable.scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
||||
scrollable.scrollTo({ y: offset, animated: false });
|
||||
}
|
||||
},
|
||||
[scrollableRef],
|
||||
|
||||
@@ -248,8 +248,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
const scrollContentContainerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
|
||||
[theme.spacing, insets.bottom],
|
||||
@@ -266,20 +264,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} />
|
||||
<View style={styles.copyBlock}>
|
||||
{isConnectingToSavedHosts ? (
|
||||
<Text style={styles.subtitle}>Connecting…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
|
||||
@@ -14,11 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreatePaseoWorktreeInput,
|
||||
DaemonClient,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -73,24 +69,19 @@ function buildChatDraftComposerArgs({
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceCreationAttachments = NonNullable<CreatePaseoWorktreeInput["attachments"]>;
|
||||
|
||||
async function callWorkspaceCreation({
|
||||
creationMethod,
|
||||
connectedClient,
|
||||
input,
|
||||
wirePayload,
|
||||
}: {
|
||||
creationMethod: "create_worktree" | "open_project";
|
||||
connectedClient: DaemonClient;
|
||||
input: { cwd: string };
|
||||
wirePayload: { attachments: WorkspaceCreationAttachments };
|
||||
}) {
|
||||
if (creationMethod === "create_worktree") {
|
||||
return connectedClient.createPaseoWorktree({
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: createNameId(),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
});
|
||||
}
|
||||
return connectedClient.openProject(input.cwd);
|
||||
@@ -228,12 +219,10 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
const connectedClient = withConnectedClient();
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const payload = await callWorkspaceCreation({
|
||||
creationMethod: pendingWorkspaceSetup.creationMethod,
|
||||
connectedClient,
|
||||
input,
|
||||
wirePayload,
|
||||
});
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
processTimelineResponse,
|
||||
type ProcessTimelineResponseOutput,
|
||||
type TimelineReducerSideEffect,
|
||||
} from "@/contexts/session-stream-reducers";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
@@ -41,7 +38,6 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -59,7 +55,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -451,6 +450,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
|
||||
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
@@ -541,6 +541,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
@@ -748,17 +751,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (isNative) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agentId = session?.focusedAgentId;
|
||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||
if (agentId && cursor) {
|
||||
if (agentId) {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1024,7 +1025,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1055,6 +1056,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
|
||||
|
||||
setAgentTimelineHasOlder(serverId, (prev) => {
|
||||
if (prev.get(agentId) === payload.hasOlder) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, payload.hasOlder);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (payload.agent) {
|
||||
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
|
||||
applyAuthoritativeAgentSnapshot({
|
||||
@@ -1127,6 +1137,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentStreamHead,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setAgentTimelineHasOlder,
|
||||
setInitializingAgents,
|
||||
],
|
||||
);
|
||||
@@ -1186,7 +1197,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
const streamEvent = event;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
@@ -1229,18 +1240,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: String(message.payload.id),
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
if (
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
serverId,
|
||||
workspace,
|
||||
isArchivePending: isLocalWorktreeArchivePending,
|
||||
})
|
||||
) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import { deriveOptimisticLifecycleStatus } from "./session-stream-lifecycle";
|
||||
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "failed",
|
||||
};
|
||||
|
||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
reason: "interrupted",
|
||||
};
|
||||
|
||||
describe("session stream lifecycle helpers", () => {
|
||||
it("derives optimistic terminal lifecycle only when current status is running", () => {
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
|
||||
export function deriveOptimisticLifecycleStatus(
|
||||
currentStatus: AgentLifecycleStatus,
|
||||
event: AgentStreamEventPayload,
|
||||
): AgentLifecycleStatus | null {
|
||||
if (currentStatus !== "running") {
|
||||
return null;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_completed":
|
||||
return "idle";
|
||||
case "turn_failed":
|
||||
return "error";
|
||||
case "turn_canceled":
|
||||
// A canceled turn can be either a final user cancel or an interrupt before
|
||||
// a replacement turn starts. The daemon snapshot is authoritative here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
let acceptedWithoutBootstrap = 0;
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
acceptedWithoutBootstrap += 1;
|
||||
}
|
||||
}
|
||||
expect(acceptedWithoutBootstrap).toBe(0);
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(false);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResolveTimelineInit", () => {
|
||||
it("resolves tail init when the tail response arrives", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve tail init when an after response arrives first", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves after init when an after response arrives", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type BootstrapTailCursor = {
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
export function deriveInitialTimelineRequest({
|
||||
cursor,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
}: {
|
||||
cursor: InitialTimelineCursor;
|
||||
hasAuthoritativeHistory: boolean;
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
}
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user