mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00870740ef |
@@ -51,7 +51,7 @@ jobs:
|
|||||||
release create "$RELEASE_TAG"
|
release create "$RELEASE_TAG"
|
||||||
--repo "${{ github.repository }}"
|
--repo "${{ github.repository }}"
|
||||||
--title "Paseo $RELEASE_TAG"
|
--title "Paseo $RELEASE_TAG"
|
||||||
--notes ""
|
--generate-notes
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
|||||||
@@ -132,9 +132,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'CHANGELOG.md'
|
|
||||||
- 'packages/website/**'
|
- 'packages/website/**'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'patches/**'
|
- 'patches/**'
|
||||||
- '.github/workflows/deploy-website.yml'
|
- '.github/workflows/deploy-website.yml'
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
gh release create "$RELEASE_TAG" \
|
gh release create "$RELEASE_TAG" \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--notes "" \
|
--generate-notes \
|
||||||
$prerelease_flag || {
|
$prerelease_flag || {
|
||||||
echo "Release creation raced with another workflow; continuing."
|
echo "Release creation raced with another workflow; continuing."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create_if_missing="false"
|
create_if_missing="false"
|
||||||
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||||
create_if_missing="true"
|
|
||||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
|
||||||
create_if_missing="true"
|
create_if_missing="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+3
-70
@@ -1,77 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.62 - 2026-04-23
|
## 0.1.60-beta.1 - 2026-04-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
|
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
||||||
|
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
||||||
### Improved
|
|
||||||
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Pull request status resolves correctly for PRs opened from forks.
|
|
||||||
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
|
|
||||||
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
|
|
||||||
|
|
||||||
## 0.1.61 - 2026-04-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
|
|
||||||
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Pi loads your installed extensions on startup so their models show up in the model picker.
|
|
||||||
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
|
|
||||||
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
|
|
||||||
|
|
||||||
## 0.1.60 - 2026-04-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
|
|
||||||
- Launch scripts and services for a worktree directly from the workspace header.
|
|
||||||
- New Setup tab in every workspace showing setup, teardown, and script progress live.
|
|
||||||
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
|
|
||||||
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
|
|
||||||
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
|
|
||||||
- Pull request pane in the workspace sidebar.
|
|
||||||
- Redesigned Settings screen with modular section navigation.
|
|
||||||
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
|
|
||||||
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
|
|
||||||
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
|
|
||||||
- New-workspace picker ranks branches by recency with fast search.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
|
|
||||||
- Agent streams render more smoothly during heavy tool output.
|
|
||||||
- App startup routes through a stable connection and lands on the right screen without flicker.
|
|
||||||
- Provider refresh is reliable and no longer stalls on transient failures.
|
|
||||||
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
|
|
||||||
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
|
|
||||||
- In-app notifications route to whichever surface you're actually looking at.
|
|
||||||
- Keyboard shortcuts keep working while Settings is open.
|
|
||||||
- Escape reliably interrupts the active agent.
|
|
||||||
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
|
|
||||||
- `paseo ls` defaults to active agents; pass `-a` to include archived.
|
|
||||||
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Composer textarea shrinks back down after sending on web.
|
|
||||||
- New workspace drafts clear after submit instead of sticking around.
|
|
||||||
- Replacing a running agent cleans up the previous one without leaving it behind.
|
|
||||||
- Agent notifications no longer get swallowed by a backgrounded focused client.
|
|
||||||
- Removed workspace folders disappear from the workspace list again.
|
|
||||||
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
|
|
||||||
- Workspace tab focus is preserved across page refreshes.
|
|
||||||
- Settings screen no longer pushes its header down with extra spacing.
|
|
||||||
- Branch switcher title no longer overflows on narrow rows.
|
|
||||||
- iOS image picker no longer leaves the screen unresponsive after cancelling.
|
|
||||||
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
|
|
||||||
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
|
|
||||||
- Tool call blocks expand correctly on mobile while an agent is still streaming.
|
|
||||||
- Timeline no longer stutters when catch-up and projected ranges overlap.
|
|
||||||
- Codex no longer flashes idle when a replacement turn is in progress.
|
|
||||||
- Branch state recovers correctly when a rebase is in progress.
|
|
||||||
- Workspace hover card no longer clips near screen edges.
|
|
||||||
|
|
||||||
## 0.1.59 - 2026-04-16
|
## 0.1.59 - 2026-04-16
|
||||||
|
|
||||||
|
|||||||
@@ -424,48 +424,6 @@ Models and modes are discovered dynamically at runtime from the agent process. I
|
|||||||
|
|
||||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||||
|
|
||||||
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
|
|
||||||
|
|
||||||
Example: add an experimental model while keeping every model the provider discovers at runtime:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"my-agent": {
|
|
||||||
"extends": "acp",
|
|
||||||
"label": "My Agent",
|
|
||||||
"command": ["my-agent", "--acp"],
|
|
||||||
"additionalModels": [
|
|
||||||
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example: relabel a discovered model without replacing the full list:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"my-agent": {
|
|
||||||
"extends": "acp",
|
|
||||||
"label": "My Agent",
|
|
||||||
"command": ["my-agent", "--acp"],
|
|
||||||
"additionalModels": [
|
|
||||||
{ "id": "provider/model-id", "label": "My Preferred Label" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Provider override reference
|
## Provider override reference
|
||||||
@@ -480,7 +438,6 @@ Every entry under `agents.providers` accepts these fields:
|
|||||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
|
||||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||||
| `order` | `number` | No | Sort order in the provider list |
|
| `order` | `number` | No | Sort order in the provider list |
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ PASEO_HOME=~/.paseo-blue npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||||
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
|
||||||
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
|
|
||||||
and `config.json`; actual checkout/worktree directories are not copied.
|
|
||||||
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
|
|
||||||
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
|
|
||||||
|
|
||||||
### Default ports
|
### Default ports
|
||||||
|
|
||||||
|
|||||||
@@ -110,33 +110,6 @@ See `image-picker-repro.yaml` for an example.
|
|||||||
|
|
||||||
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
### New Workspace Creation
|
|
||||||
|
|
||||||
The Android workspace-creation regression has a dedicated harness:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For a short recording that starts after launch/connection/sidebar setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
|
|
||||||
|
|
||||||
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
|
|
||||||
|
|
||||||
- `android-dev-client.yaml`
|
|
||||||
- `connect-direct-if-welcome.yaml`
|
|
||||||
- `open-prepared-project-sidebar.yaml`
|
|
||||||
- `new-workspace-open-from-sidebar.yaml`
|
|
||||||
- `new-workspace-select-codex-gpt54.yaml`
|
|
||||||
- `new-workspace-submit-and-assert-created.yaml`
|
|
||||||
|
|
||||||
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
|
|
||||||
|
|
||||||
### Inputs that Maestro types into
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-OF3YosIWd00SAKiDGXo3yXsWASTmRSJVF3a9g7uTpEk=";
|
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+30
-114
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -12771,6 +12771,7 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -12791,6 +12792,7 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -12804,6 +12806,7 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -12818,7 +12821,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/react": {
|
"node_modules/@testing-library/react": {
|
||||||
"version": "16.3.2",
|
"version": "16.3.2",
|
||||||
@@ -12878,20 +12882,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/user-event": {
|
|
||||||
"version": "14.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
|
||||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12",
|
|
||||||
"npm": ">=6"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": ">=7.21.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -12982,7 +12972,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -14154,42 +14145,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/browser": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@testing-library/dom": "^10.4.0",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@vitest/mocker": "3.2.4",
|
|
||||||
"@vitest/utils": "3.2.4",
|
|
||||||
"magic-string": "^0.30.17",
|
|
||||||
"sirv": "^3.0.1",
|
|
||||||
"tinyrainbow": "^2.0.0",
|
|
||||||
"ws": "^8.18.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"playwright": "*",
|
|
||||||
"vitest": "3.2.4",
|
|
||||||
"webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"playwright": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"safaridriver": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"webdriverio": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
@@ -15039,6 +14994,7 @@
|
|||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -18163,7 +18119,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -27362,6 +27319,7 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -28721,18 +28679,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mimic-function": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mimic-response": {
|
"node_modules/mimic-response": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||||
@@ -30121,34 +30067,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/p-memoize": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/p-memoize/-/p-memoize-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-jdZ10MCxavHoIHwJ5oweOtYy6ElPixEHaMkz0AuaEMovR1MRpVvYFzIEHRxgMEpXYzNpRVByFAniAzwmd1/uug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mimic-function": "^5.0.1",
|
|
||||||
"type-fest": "^4.41.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/p-memoize?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/p-memoize/node_modules/type-fest": {
|
|
||||||
"version": "4.41.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
|
||||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/p-retry": {
|
"node_modules/p-retry": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||||
@@ -37385,16 +37303,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.62",
|
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.62",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.62",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -37473,7 +37391,6 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/browser": "^3.2.4",
|
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
@@ -37539,11 +37456,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.62",
|
"@getpaseo/relay": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.62",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37584,11 +37501,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.62",
|
"@getpaseo/cli": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.62",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -37622,7 +37539,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -37823,7 +37740,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -37849,7 +37766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -37865,14 +37782,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.62",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/relay": "0.1.62",
|
"@getpaseo/relay": "0.1.60-beta.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-coding-agent": "^0.67.68",
|
"@mariozechner/pi-coding-agent": "^0.67.68",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
@@ -37891,7 +37808,6 @@
|
|||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"p-memoize": "^8.0.0",
|
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -38627,7 +38543,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
archiveAgentFromSessions,
|
archiveAgentFromSessions,
|
||||||
clickSessionRow,
|
|
||||||
closeWorkspaceAgentTab,
|
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
expectArchivedAgentFocused,
|
|
||||||
expectSessionRowArchived,
|
|
||||||
expectSessionRowVisible,
|
expectSessionRowVisible,
|
||||||
expectWorkspaceArchiveOutcome,
|
expectWorkspaceArchiveOutcome,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
@@ -104,26 +100,4 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
await passivePage.close();
|
await passivePage.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
|
||||||
const archived = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
const surviving = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await resetSeededPageState(page);
|
|
||||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
|
||||||
await closeWorkspaceAgentTab(page, archived.id);
|
|
||||||
await archiveAgentFromDaemon(client, archived.id);
|
|
||||||
await openSessions(page);
|
|
||||||
await expectSessionRowArchived(page, archived.title);
|
|
||||||
|
|
||||||
await clickSessionRow(page, archived.title);
|
|
||||||
|
|
||||||
await expectArchivedAgentFocused(page, archived.id);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -186,17 +185,6 @@ let paseoHome: string | null = null;
|
|||||||
let fakeGhBinDir: string | null = null;
|
let fakeGhBinDir: string | null = null;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
|
||||||
const trimmed = value?.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (trimmed === "current") {
|
|
||||||
return resolvePaseoHomePath("~/.paseo");
|
|
||||||
}
|
|
||||||
return resolvePaseoHomePath(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
type OfferPayload = {
|
type OfferPayload = {
|
||||||
v: 2;
|
v: 2;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -232,11 +220,6 @@ if (args[0] === "pr" && args[1] === "list") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
|
||||||
console.error("no pull requests found for branch");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args[0] === "issue" && args[1] === "list") {
|
if (args[0] === "issue" && args[1] === "list") {
|
||||||
console.log("[]");
|
console.log("[]");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -339,36 +322,12 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
const metroPort = await getAvailablePort();
|
||||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
|
||||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
|
||||||
fakeGhBinDir = await createFakeGhBin();
|
fakeGhBinDir = await createFakeGhBin();
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
|
|
||||||
if (forkSourceHome) {
|
|
||||||
const forkResult = await forkPaseoHomeMetadata({
|
|
||||||
sourceHome: forkSourceHome,
|
|
||||||
targetHome: paseoHome,
|
|
||||||
});
|
|
||||||
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
|
|
||||||
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
|
|
||||||
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
|
|
||||||
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
|
|
||||||
console.log(
|
|
||||||
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
|
|
||||||
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
|
|
||||||
`${forkResult.copiedBytes} bytes)`,
|
|
||||||
);
|
|
||||||
if (forkResult.skippedMissing.length > 0) {
|
|
||||||
console.warn(
|
|
||||||
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
stopProcess(daemonProcess),
|
stopProcess(daemonProcess),
|
||||||
@@ -378,11 +337,9 @@ export default async function globalSetup() {
|
|||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
if (paseoHome && shouldRemovePaseoHome) {
|
if (paseoHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
paseoHome = null;
|
paseoHome = null;
|
||||||
} else if (paseoHome) {
|
|
||||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
|
||||||
}
|
}
|
||||||
if (fakeGhBinDir) {
|
if (fakeGhBinDir) {
|
||||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -231,24 +231,6 @@ export async function expectWorkspaceArchiveOutcome(
|
|||||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
|
||||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
|
||||||
visible: true,
|
|
||||||
});
|
|
||||||
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await closeButton.first().click();
|
|
||||||
await expectWorkspaceTabHidden(page, agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
|
||||||
await expectWorkspaceTabVisible(page, agentId);
|
|
||||||
await expect(
|
|
||||||
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
|
||||||
).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||||
@@ -279,12 +261,6 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
|||||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
|
||||||
const row = getSessionRowByTitle(page, title);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveAgentFromSessions(
|
export async function archiveAgentFromSessions(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { agentId: string; title: string },
|
input: { agentId: string; title: string },
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { existsSync } from "node:fs";
|
|
||||||
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export type PaseoHomeMetadataForkResult = {
|
|
||||||
sourceHome: string;
|
|
||||||
targetHome: string;
|
|
||||||
agentFiles: number;
|
|
||||||
agentBytes: number;
|
|
||||||
projectFiles: number;
|
|
||||||
projectBytes: number;
|
|
||||||
copiedFiles: number;
|
|
||||||
copiedBytes: number;
|
|
||||||
skippedMissing: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type CopyStats = {
|
|
||||||
files: number;
|
|
||||||
bytes: number;
|
|
||||||
skippedMissing: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolvePaseoHomePath(value: string): string {
|
|
||||||
if (value === "~") {
|
|
||||||
return homedir();
|
|
||||||
}
|
|
||||||
if (value.startsWith("~/")) {
|
|
||||||
return path.join(homedir(), value.slice(2));
|
|
||||||
}
|
|
||||||
return path.resolve(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
|
|
||||||
if (!existsSync(sourceDir)) {
|
|
||||||
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
|
||||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
||||||
await mkdir(targetDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const sourcePath = path.join(sourceDir, entry.name);
|
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const nested = await copyJsonTree(sourcePath, targetPath);
|
|
||||||
stats.files += nested.files;
|
|
||||||
stats.bytes += nested.bytes;
|
|
||||||
stats.skippedMissing.push(...nested.skippedMissing);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await copyFile(sourcePath, targetPath);
|
|
||||||
const fileStat = await stat(sourcePath);
|
|
||||||
stats.files += 1;
|
|
||||||
stats.bytes += fileStat.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyProjectRegistryFiles(
|
|
||||||
sourceHome: string,
|
|
||||||
targetHome: string,
|
|
||||||
): Promise<CopyStats> {
|
|
||||||
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
|
||||||
const sourceProjectsDir = path.join(sourceHome, "projects");
|
|
||||||
const targetProjectsDir = path.join(targetHome, "projects");
|
|
||||||
await mkdir(targetProjectsDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const fileName of ["projects.json", "workspaces.json"]) {
|
|
||||||
const sourcePath = path.join(sourceProjectsDir, fileName);
|
|
||||||
const targetPath = path.join(targetProjectsDir, fileName);
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
stats.skippedMissing.push(sourcePath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await copyFile(sourcePath, targetPath);
|
|
||||||
const fileStat = await stat(sourcePath);
|
|
||||||
stats.files += 1;
|
|
||||||
stats.bytes += fileStat.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function forkPaseoHomeMetadata(input: {
|
|
||||||
sourceHome: string;
|
|
||||||
targetHome: string;
|
|
||||||
}): Promise<PaseoHomeMetadataForkResult> {
|
|
||||||
const sourceHome = resolvePaseoHomePath(input.sourceHome);
|
|
||||||
const targetHome = resolvePaseoHomePath(input.targetHome);
|
|
||||||
|
|
||||||
if (sourceHome === targetHome) {
|
|
||||||
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(targetHome, { recursive: true });
|
|
||||||
|
|
||||||
// Reset only the copied metadata surface. In particular, do not copy or remove
|
|
||||||
// worktrees here: forked workspace records should continue to point at the
|
|
||||||
// original checkout/worktree paths from the source home.
|
|
||||||
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
|
|
||||||
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
|
|
||||||
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
|
|
||||||
|
|
||||||
const agents = await copyJsonTree(
|
|
||||||
path.join(sourceHome, "agents"),
|
|
||||||
path.join(targetHome, "agents"),
|
|
||||||
);
|
|
||||||
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceHome,
|
|
||||||
targetHome,
|
|
||||||
agentFiles: agents.files,
|
|
||||||
agentBytes: agents.bytes,
|
|
||||||
projectFiles: projects.files,
|
|
||||||
projectBytes: projects.bytes,
|
|
||||||
copiedFiles: agents.files + projects.files,
|
|
||||||
copiedBytes: agents.bytes + projects.bytes,
|
|
||||||
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { expect, type Page } from "../fixtures";
|
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
||||||
|
|
||||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
|
||||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
|
||||||
const E2E_KEY = "@paseo:e2e";
|
|
||||||
|
|
||||||
type SavedHostInput = {
|
|
||||||
serverId: string;
|
|
||||||
label: string;
|
|
||||||
endpoint: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function startupScenario(page: Page) {
|
|
||||||
return new StartupScenario(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
class StartupScenario {
|
|
||||||
private readonly page: Page;
|
|
||||||
private savedHosts: SavedHostInput[] = [];
|
|
||||||
private desktopBridge = false;
|
|
||||||
private blockedEndpointPorts = new Set<string>();
|
|
||||||
private viewport: { width: number; height: number } | null = null;
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
withMobileViewport(): this {
|
|
||||||
this.viewport = { width: 390, height: 844 };
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
withSavedHost(input: SavedHostInput): this {
|
|
||||||
this.savedHosts.push(input);
|
|
||||||
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
|
||||||
if (port) {
|
|
||||||
this.blockedEndpointPorts.add(port);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
withPendingDesktopDaemon(): this {
|
|
||||||
this.desktopBridge = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
withBlockedPort(port: string): this {
|
|
||||||
this.blockedEndpointPorts.add(port);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async openRoot(): Promise<StartupAssertions> {
|
|
||||||
await this.prepare();
|
|
||||||
await this.page.goto("/");
|
|
||||||
return new StartupAssertions(this.page);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openHostWorkspace(input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}): Promise<StartupAssertions> {
|
|
||||||
await this.prepare();
|
|
||||||
await this.page.goto(
|
|
||||||
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
|
||||||
);
|
|
||||||
return new StartupAssertions(this.page);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async prepare(): Promise<void> {
|
|
||||||
if (this.viewport) {
|
|
||||||
await this.page.setViewportSize(this.viewport);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.desktopBridge) {
|
|
||||||
await installPendingDesktopBridge(this.page);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const port of this.blockedEndpointPorts) {
|
|
||||||
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
|
||||||
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.savedHosts.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let the shared fixture create its seed nonce, then opt out of that seed for
|
|
||||||
// the next navigation so this scenario owns the stored host registry.
|
|
||||||
await this.page.goto("/");
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const registry = this.savedHosts.map((host) =>
|
|
||||||
buildStoredHost({
|
|
||||||
serverId: host.serverId,
|
|
||||||
endpoint: host.endpoint,
|
|
||||||
label: host.label,
|
|
||||||
nowIso,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const firstHost = registry[0];
|
|
||||||
if (!firstHost) {
|
|
||||||
throw new Error("Expected at least one startup test host.");
|
|
||||||
}
|
|
||||||
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
|
||||||
|
|
||||||
await this.page.evaluate(
|
|
||||||
({ keys, registry, createAgentPreferences }) => {
|
|
||||||
const nonce = localStorage.getItem(keys.seedNonce);
|
|
||||||
if (!nonce) {
|
|
||||||
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(keys.e2e, "1");
|
|
||||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
|
||||||
localStorage.setItem(
|
|
||||||
"@paseo:create-agent-preferences",
|
|
||||||
JSON.stringify(createAgentPreferences),
|
|
||||||
);
|
|
||||||
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: {
|
|
||||||
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
|
||||||
e2e: E2E_KEY,
|
|
||||||
registry: REGISTRY_KEY,
|
|
||||||
seedNonce: SEED_NONCE_KEY,
|
|
||||||
},
|
|
||||||
registry,
|
|
||||||
createAgentPreferences,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StartupAssertions {
|
|
||||||
private readonly page: Page;
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
|
||||||
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
|
||||||
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
|
||||||
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectsDesktopDaemonStartup(): Promise<this> {
|
|
||||||
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
|
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectsSidebarHidden(): Promise<this> {
|
|
||||||
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
|
||||||
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectsNoUndefinedRoute(): Promise<this> {
|
|
||||||
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
|
||||||
platform: "darwin",
|
|
||||||
invoke: async (command: string) => {
|
|
||||||
if (command === "start_desktop_daemon") {
|
|
||||||
await new Promise(() => {
|
|
||||||
// Keep the daemon in the startup phase until the test ends.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (command === "desktop_daemon_status") {
|
|
||||||
return {
|
|
||||||
serverId: "srv_desktop_pending",
|
|
||||||
status: "starting",
|
|
||||||
listen: null,
|
|
||||||
hostname: null,
|
|
||||||
pid: null,
|
|
||||||
home: "",
|
|
||||||
version: null,
|
|
||||||
desktopManaged: true,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (command === "desktop_daemon_logs") {
|
|
||||||
return { logPath: "", contents: "" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
getPendingOpenProject: async () => null,
|
|
||||||
events: { on: async () => () => undefined },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStoredHost(input: {
|
|
||||||
serverId: string;
|
|
||||||
endpoint: string;
|
|
||||||
label: string;
|
|
||||||
nowIso: string;
|
|
||||||
}) {
|
|
||||||
return buildSeededHost(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
|
||||||
return buildCreateAgentPreferences(serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { createTempGitRepo } from "./workspace";
|
|
||||||
import {
|
|
||||||
connectTerminalClient,
|
|
||||||
navigateToTerminal,
|
|
||||||
setupDeterministicPrompt,
|
|
||||||
type TerminalPerfDaemonClient,
|
|
||||||
} from "./terminal-perf";
|
|
||||||
|
|
||||||
type TempRepo = {
|
|
||||||
path: string;
|
|
||||||
cleanup: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalInstance = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
cwd: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TerminalE2EHarness {
|
|
||||||
readonly client: TerminalPerfDaemonClient;
|
|
||||||
readonly tempRepo: TempRepo;
|
|
||||||
readonly workspaceId: string;
|
|
||||||
|
|
||||||
private constructor(input: {
|
|
||||||
client: TerminalPerfDaemonClient;
|
|
||||||
tempRepo: TempRepo;
|
|
||||||
workspaceId: string;
|
|
||||||
}) {
|
|
||||||
this.client = input.client;
|
|
||||||
this.tempRepo = input.tempRepo;
|
|
||||||
this.workspaceId = input.workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
|
||||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
|
||||||
const client = await connectTerminalClient();
|
|
||||||
const seedResult = await client.openProject(tempRepo.path);
|
|
||||||
if (!seedResult.workspace) {
|
|
||||||
await client.close().catch(() => {});
|
|
||||||
await tempRepo.cleanup().catch(() => {});
|
|
||||||
throw new Error(seedResult.error ?? "Failed to seed workspace");
|
|
||||||
}
|
|
||||||
return new TerminalE2EHarness({
|
|
||||||
client,
|
|
||||||
tempRepo,
|
|
||||||
workspaceId: seedResult.workspace.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanup(): Promise<void> {
|
|
||||||
await this.client.close().catch(() => {});
|
|
||||||
await this.tempRepo.cleanup().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
|
||||||
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
|
||||||
if (!result.terminal) {
|
|
||||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
|
||||||
}
|
|
||||||
return result.terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
async killTerminal(terminalId: string): Promise<void> {
|
|
||||||
await this.client.killTerminal(terminalId).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
|
|
||||||
await navigateToTerminal(page, {
|
|
||||||
workspaceId: this.workspaceId,
|
|
||||||
terminalId: input.terminalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
terminalSurface(page: Page) {
|
|
||||||
return page.locator('[data-testid="terminal-surface"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
|
|
||||||
await setupDeterministicPrompt(page, sentinel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withTerminalInApp<T>(
|
|
||||||
page: Page,
|
|
||||||
harness: TerminalE2EHarness,
|
|
||||||
input: { name: string },
|
|
||||||
fn: (terminal: TerminalInstance) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const terminal = await harness.createTerminal({ name: input.name });
|
|
||||||
try {
|
|
||||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
|
||||||
return await fn(terminal);
|
|
||||||
} finally {
|
|
||||||
await harness.killTerminal(terminal.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,13 +19,6 @@ export type TerminalPerfDaemonClient = {
|
|||||||
terminal: { id: string; name: string; cwd: string } | null;
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
createAgent(options: {
|
|
||||||
provider: string;
|
|
||||||
cwd: string;
|
|
||||||
title?: string;
|
|
||||||
modeId?: string;
|
|
||||||
}): Promise<{ id: string; status: string }>;
|
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
|||||||
@@ -1,680 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export type TerminalRenderProbeSnapshot = {
|
|
||||||
setCount: number;
|
|
||||||
unsetCount: number;
|
|
||||||
writeCount: number;
|
|
||||||
resetWrites: number;
|
|
||||||
clearScreenWrites: number;
|
|
||||||
altEnterWrites: number;
|
|
||||||
altExitWrites: number;
|
|
||||||
events: TerminalRenderProbeEvent[];
|
|
||||||
frames: TerminalFrame[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalRenderProbeEvent = {
|
|
||||||
at: number;
|
|
||||||
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
|
|
||||||
preview?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalFrame = {
|
|
||||||
at: number;
|
|
||||||
rowCount: number;
|
|
||||||
nonEmptyRows: number;
|
|
||||||
firstNonEmptyRow: number | null;
|
|
||||||
text: string;
|
|
||||||
topText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
|
|
||||||
frameCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalKeystrokeStressReport = {
|
|
||||||
inputTextLength: number;
|
|
||||||
keydownCount: number;
|
|
||||||
inputFrameCount: number;
|
|
||||||
outputFrameCount: number;
|
|
||||||
textMessageFrameCount: number;
|
|
||||||
textMessagePayloadBytes: number;
|
|
||||||
largeTextMessageCount: number;
|
|
||||||
largestTextMessageBytes: number;
|
|
||||||
agentStreamTextMessageCount: number;
|
|
||||||
agentStreamTextMessagePayloadBytes: number;
|
|
||||||
largeAgentStreamTextMessageCount: number;
|
|
||||||
largestAgentStreamTextMessageBytes: number;
|
|
||||||
appEventCount: number;
|
|
||||||
appEventCounts: Record<string, number>;
|
|
||||||
runtimeMaxQueueDepth: number;
|
|
||||||
xtermWriteCount: number;
|
|
||||||
inputFramePayloadBytes: number;
|
|
||||||
outputFramePayloadBytes: number;
|
|
||||||
keydownToInputFrameMs: LatencyStats | null;
|
|
||||||
inputFrameToOutputFrameMs: LatencyStats | null;
|
|
||||||
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
|
|
||||||
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
|
|
||||||
appTerminalEmitListenerDurationMs: LatencyStats | null;
|
|
||||||
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
|
|
||||||
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
|
|
||||||
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
|
|
||||||
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
|
|
||||||
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
|
|
||||||
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
|
|
||||||
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
|
|
||||||
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
|
|
||||||
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
|
|
||||||
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
|
|
||||||
outputFrameToXtermWriteMs: LatencyStats | null;
|
|
||||||
xtermWriteDurationMs: LatencyStats | null;
|
|
||||||
keydownToXtermCommitMs: LatencyStats | null;
|
|
||||||
firstKeydownAt: number | null;
|
|
||||||
lastXtermCommitAt: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LatencyStats = {
|
|
||||||
count: number;
|
|
||||||
minMs: number;
|
|
||||||
p50Ms: number;
|
|
||||||
p95Ms: number;
|
|
||||||
maxMs: number;
|
|
||||||
avgMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
type ProbeState = {
|
|
||||||
term: any;
|
|
||||||
setCount: number;
|
|
||||||
unsetCount: number;
|
|
||||||
writeCount: number;
|
|
||||||
resetWrites: number;
|
|
||||||
clearScreenWrites: number;
|
|
||||||
altEnterWrites: number;
|
|
||||||
altExitWrites: number;
|
|
||||||
events: TerminalRenderProbeEvent[];
|
|
||||||
frames: TerminalFrame[];
|
|
||||||
rafId: number | null;
|
|
||||||
sampleUntil: number;
|
|
||||||
reset: () => void;
|
|
||||||
snapshot: () => TerminalRenderProbeSnapshot;
|
|
||||||
startSampling: (durationMs: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const win = window as any;
|
|
||||||
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
|
|
||||||
const getExisting = () =>
|
|
||||||
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
|
|
||||||
|
|
||||||
const probe: ProbeState = {
|
|
||||||
term: getExisting(),
|
|
||||||
setCount: 0,
|
|
||||||
unsetCount: 0,
|
|
||||||
writeCount: 0,
|
|
||||||
resetWrites: 0,
|
|
||||||
clearScreenWrites: 0,
|
|
||||||
altEnterWrites: 0,
|
|
||||||
altExitWrites: 0,
|
|
||||||
events: [],
|
|
||||||
frames: [],
|
|
||||||
rafId: null,
|
|
||||||
sampleUntil: 0,
|
|
||||||
reset() {
|
|
||||||
this.setCount = 0;
|
|
||||||
this.unsetCount = 0;
|
|
||||||
this.writeCount = 0;
|
|
||||||
this.resetWrites = 0;
|
|
||||||
this.clearScreenWrites = 0;
|
|
||||||
this.altEnterWrites = 0;
|
|
||||||
this.altExitWrites = 0;
|
|
||||||
this.events = [];
|
|
||||||
this.frames = [];
|
|
||||||
},
|
|
||||||
snapshot() {
|
|
||||||
return {
|
|
||||||
setCount: this.setCount,
|
|
||||||
unsetCount: this.unsetCount,
|
|
||||||
writeCount: this.writeCount,
|
|
||||||
resetWrites: this.resetWrites,
|
|
||||||
clearScreenWrites: this.clearScreenWrites,
|
|
||||||
altEnterWrites: this.altEnterWrites,
|
|
||||||
altExitWrites: this.altExitWrites,
|
|
||||||
events: this.events,
|
|
||||||
frames: this.frames,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
startSampling(durationMs: number) {
|
|
||||||
this.sampleUntil = performance.now() + durationMs;
|
|
||||||
if (this.rafId !== null) {
|
|
||||||
cancelAnimationFrame(this.rafId);
|
|
||||||
}
|
|
||||||
const sample = () => {
|
|
||||||
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
|
|
||||||
(row) => row.textContent ?? "",
|
|
||||||
);
|
|
||||||
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
|
|
||||||
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
|
|
||||||
this.frames.push({
|
|
||||||
at: performance.now(),
|
|
||||||
rowCount: rows.length,
|
|
||||||
nonEmptyRows: nonEmptyRows.length,
|
|
||||||
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
|
|
||||||
text: rows.join("\n"),
|
|
||||||
topText: rows.slice(0, 3).join("\n"),
|
|
||||||
});
|
|
||||||
if (performance.now() < this.sampleUntil) {
|
|
||||||
this.rafId = requestAnimationFrame(sample);
|
|
||||||
} else {
|
|
||||||
this.rafId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.rafId = requestAnimationFrame(sample);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(win, "__terminalRenderProbe", {
|
|
||||||
configurable: true,
|
|
||||||
value: probe,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(win, "__paseoTerminal", {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
return probe.term;
|
|
||||||
},
|
|
||||||
set(next: any) {
|
|
||||||
if (next === undefined) {
|
|
||||||
probe.unsetCount += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "unset" });
|
|
||||||
probe.term = next;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
probe.setCount += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "set" });
|
|
||||||
probe.term = next;
|
|
||||||
|
|
||||||
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);
|
|
||||||
probe.writeCount += 1;
|
|
||||||
const preview = text
|
|
||||||
.replace(/\u001b/g, "\\x1b")
|
|
||||||
.replace(/\r/g, "\\r")
|
|
||||||
.replace(/\n/g, "\\n")
|
|
||||||
.slice(0, 160);
|
|
||||||
if (text.includes("\u001bc")) {
|
|
||||||
probe.resetWrites += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "reset-write", preview });
|
|
||||||
}
|
|
||||||
if (text.includes("\u001b[2J")) {
|
|
||||||
probe.clearScreenWrites += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "clear-write", preview });
|
|
||||||
}
|
|
||||||
if (text.includes("\u001b[?1049h")) {
|
|
||||||
probe.altEnterWrites += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
|
|
||||||
}
|
|
||||||
if (text.includes("\u001b[?1049l")) {
|
|
||||||
probe.altExitWrites += 1;
|
|
||||||
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
|
|
||||||
}
|
|
||||||
return originalWrite(data, callback);
|
|
||||||
};
|
|
||||||
next.__paseoRenderProbeWriteWrapped = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
(window as any).__terminalRenderProbe.reset();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
|
|
||||||
await page.evaluate((ms) => {
|
|
||||||
(window as any).__terminalRenderProbe.startSampling(ms);
|
|
||||||
}, durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
|
|
||||||
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function terminalVisibleText(page: Page): Promise<string> {
|
|
||||||
return page.evaluate(() =>
|
|
||||||
Array.from(document.querySelectorAll(".xterm-rows > div"))
|
|
||||||
.map((row) => row.textContent ?? "")
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeTerminalRenderProbe(
|
|
||||||
probe: TerminalRenderProbeSnapshot,
|
|
||||||
): TerminalRenderProbeSummary {
|
|
||||||
return {
|
|
||||||
setCount: probe.setCount,
|
|
||||||
unsetCount: probe.unsetCount,
|
|
||||||
writeCount: probe.writeCount,
|
|
||||||
resetWrites: probe.resetWrites,
|
|
||||||
clearScreenWrites: probe.clearScreenWrites,
|
|
||||||
altEnterWrites: probe.altEnterWrites,
|
|
||||||
altExitWrites: probe.altExitWrites,
|
|
||||||
events: probe.events,
|
|
||||||
frameCount: probe.frames.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
type TimedTextEvent = {
|
|
||||||
at: number;
|
|
||||||
text: string;
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
type TimedTextMessageEvent = {
|
|
||||||
at: number;
|
|
||||||
bytes: number;
|
|
||||||
kind: string | null;
|
|
||||||
};
|
|
||||||
type XtermWriteEvent = {
|
|
||||||
at: number;
|
|
||||||
committedAt: number | null;
|
|
||||||
text: string;
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
type AppProbeEvent = {
|
|
||||||
type: string;
|
|
||||||
at: number;
|
|
||||||
bytes?: number;
|
|
||||||
queueDepth?: number;
|
|
||||||
};
|
|
||||||
type StressProbeState = {
|
|
||||||
keydowns: Array<{ at: number; key: string }>;
|
|
||||||
inputFrames: TimedTextEvent[];
|
|
||||||
outputFrames: TimedTextEvent[];
|
|
||||||
textMessageFrames: TimedTextMessageEvent[];
|
|
||||||
xtermWrites: XtermWriteEvent[];
|
|
||||||
appEvents: AppProbeEvent[];
|
|
||||||
reset: () => void;
|
|
||||||
report: (inputText: string) => TerminalKeystrokeStressReport;
|
|
||||||
};
|
|
||||||
|
|
||||||
const INPUT_OPCODE = 0x02;
|
|
||||||
const OUTPUT_OPCODE = 0x01;
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
function bytesFrom(data: unknown): Uint8Array | null {
|
|
||||||
if (data instanceof Uint8Array) return data;
|
|
||||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
|
|
||||||
const bytes = bytesFrom(data);
|
|
||||||
if (bytes) return Promise.resolve(bytes);
|
|
||||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
||||||
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
||||||
}
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameText(bytes: Uint8Array): string {
|
|
||||||
return decoder.decode(bytes.slice(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventDataText(data: unknown): Promise<string | null> {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
return Promise.resolve(data);
|
|
||||||
}
|
|
||||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
||||||
return data.text();
|
|
||||||
}
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function textMessageKind(text: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as {
|
|
||||||
type?: unknown;
|
|
||||||
message?: {
|
|
||||||
type?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
if (typeof parsed.type !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof parsed.message?.type === "string") {
|
|
||||||
return `${parsed.type}:${parsed.message.type}`;
|
|
||||||
}
|
|
||||||
return parsed.type;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(values: number[]): LatencyStats | null {
|
|
||||||
if (values.length === 0) return null;
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const percentile = (p: number) => {
|
|
||||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
|
||||||
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
|
||||||
};
|
|
||||||
const total = values.reduce((sum, value) => sum + value, 0);
|
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
|
||||||
return {
|
|
||||||
count: values.length,
|
|
||||||
minMs: round2(sorted[0] ?? 0),
|
|
||||||
p50Ms: round2(percentile(50)),
|
|
||||||
p95Ms: round2(percentile(95)),
|
|
||||||
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
|
||||||
avgMs: round2(total / values.length),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
|
|
||||||
return events.find((event) => event.at >= at) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
|
|
||||||
return (
|
|
||||||
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function countByType(events: AppProbeEvent[]): Record<string, number> {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const event of events) {
|
|
||||||
counts[event.type] = (counts[event.type] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
|
|
||||||
return events.filter((event) => event.type === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
const probe: StressProbeState = {
|
|
||||||
keydowns: [],
|
|
||||||
inputFrames: [],
|
|
||||||
outputFrames: [],
|
|
||||||
textMessageFrames: [],
|
|
||||||
xtermWrites: [],
|
|
||||||
appEvents: [],
|
|
||||||
reset() {
|
|
||||||
this.keydowns = [];
|
|
||||||
this.inputFrames = [];
|
|
||||||
this.outputFrames = [];
|
|
||||||
this.textMessageFrames = [];
|
|
||||||
this.xtermWrites = [];
|
|
||||||
this.appEvents = [];
|
|
||||||
},
|
|
||||||
report(inputText: string) {
|
|
||||||
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
|
|
||||||
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
|
|
||||||
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
|
|
||||||
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
|
|
||||||
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
|
|
||||||
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
|
|
||||||
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
|
|
||||||
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
|
|
||||||
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
|
|
||||||
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
|
|
||||||
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
|
|
||||||
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);
|
|
||||||
const inputFrameToOutputFrame = this.inputFrames
|
|
||||||
.map((input) => {
|
|
||||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
|
||||||
return output ? output.at - input.at : null;
|
|
||||||
})
|
|
||||||
.filter((value): value is number => value !== null);
|
|
||||||
const outputFrameToXtermWrite = this.outputFrames
|
|
||||||
.map((output) => {
|
|
||||||
const write = firstAtOrAfter(this.xtermWrites, output.at);
|
|
||||||
return write ? write.at - output.at : null;
|
|
||||||
})
|
|
||||||
.filter((value): value is number => value !== null);
|
|
||||||
const xtermWriteDurations = this.xtermWrites
|
|
||||||
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
|
|
||||||
.filter((value): value is number => value !== null);
|
|
||||||
const keydownToXtermCommit = this.keydowns
|
|
||||||
.map((keydown) => {
|
|
||||||
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
|
|
||||||
return write?.committedAt ? write.committedAt - keydown.at : null;
|
|
||||||
})
|
|
||||||
.filter((value): value is number => value !== null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputTextLength: inputText.length,
|
|
||||||
keydownCount: this.keydowns.length,
|
|
||||||
inputFrameCount: this.inputFrames.length,
|
|
||||||
outputFrameCount: this.outputFrames.length,
|
|
||||||
textMessageFrameCount: this.textMessageFrames.length,
|
|
||||||
textMessagePayloadBytes: this.textMessageFrames.reduce(
|
|
||||||
(sum, frame) => sum + frame.bytes,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
|
|
||||||
.length,
|
|
||||||
largestTextMessageBytes: Math.max(
|
|
||||||
0,
|
|
||||||
...this.textMessageFrames.map((frame) => frame.bytes),
|
|
||||||
),
|
|
||||||
agentStreamTextMessageCount: this.textMessageFrames.filter(
|
|
||||||
(frame) => frame.kind === "session:agent_stream",
|
|
||||||
).length,
|
|
||||||
agentStreamTextMessagePayloadBytes: this.textMessageFrames
|
|
||||||
.filter((frame) => frame.kind === "session:agent_stream")
|
|
||||||
.reduce((sum, frame) => sum + frame.bytes, 0),
|
|
||||||
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
|
|
||||||
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
|
|
||||||
).length,
|
|
||||||
largestAgentStreamTextMessageBytes: Math.max(
|
|
||||||
0,
|
|
||||||
...this.textMessageFrames
|
|
||||||
.filter((frame) => frame.kind === "session:agent_stream")
|
|
||||||
.map((frame) => frame.bytes),
|
|
||||||
),
|
|
||||||
appEventCount: this.appEvents.length,
|
|
||||||
appEventCounts: countByType(this.appEvents),
|
|
||||||
runtimeMaxQueueDepth: Math.max(
|
|
||||||
0,
|
|
||||||
...this.appEvents
|
|
||||||
.map((event) => event.queueDepth)
|
|
||||||
.filter((value): value is number => typeof value === "number"),
|
|
||||||
),
|
|
||||||
xtermWriteCount: this.xtermWrites.length,
|
|
||||||
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
|
||||||
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
|
||||||
keydownToInputFrameMs: summarize(keydownToInputFrame),
|
|
||||||
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
|
|
||||||
appBinaryReceivedToFrameDecodedMs: summarize(
|
|
||||||
latencyByIndex(binaryReceived, frameDecoded),
|
|
||||||
),
|
|
||||||
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
|
|
||||||
appTerminalEmitListenerDurationMs: summarize(
|
|
||||||
latencyByIndex(terminalEmit, terminalEmitted),
|
|
||||||
),
|
|
||||||
appTerminalEmitToStreamControllerOutputMs: summarize(
|
|
||||||
latencyByIndex(terminalEmit, streamControllerOutput),
|
|
||||||
),
|
|
||||||
appStreamControllerDecodeToOnOutputMs: summarize(
|
|
||||||
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
|
|
||||||
),
|
|
||||||
appStreamControllerToEmulatorWriteMs: summarize(
|
|
||||||
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
|
|
||||||
),
|
|
||||||
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
|
|
||||||
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
|
|
||||||
),
|
|
||||||
appRuntimeEnqueuedToOperationStartMs: summarize(
|
|
||||||
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
|
|
||||||
),
|
|
||||||
appRuntimeOperationStartToXtermWriteMs: summarize(
|
|
||||||
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
|
|
||||||
),
|
|
||||||
appRuntimeXtermWriteToCommitMs: summarize(
|
|
||||||
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
|
|
||||||
),
|
|
||||||
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
|
|
||||||
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
|
|
||||||
),
|
|
||||||
appBinaryReceivedToRuntimeOperationStartMs: summarize(
|
|
||||||
latencyByIndex(binaryReceived, runtimeOperationStart),
|
|
||||||
),
|
|
||||||
appBinaryReceivedToXtermCommitMs: summarize(
|
|
||||||
latencyByIndex(binaryReceived, runtimeXtermCommitted),
|
|
||||||
),
|
|
||||||
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
|
|
||||||
xtermWriteDurationMs: summarize(xtermWriteDurations),
|
|
||||||
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
|
|
||||||
firstKeydownAt: this.keydowns[0]?.at ?? null,
|
|
||||||
lastXtermCommitAt:
|
|
||||||
this.xtermWrites
|
|
||||||
.map((write) => write.committedAt)
|
|
||||||
.filter((at): at is number => typeof at === "number")
|
|
||||||
.at(-1) ?? null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
|
|
||||||
configurable: true,
|
|
||||||
value: probe,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
(event) => {
|
|
||||||
if (event.key.length === 1) {
|
|
||||||
probe.keydowns.push({ at: performance.now(), key: event.key });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const NativeWebSocket = window.WebSocket;
|
|
||||||
class InstrumentedWebSocket extends NativeWebSocket {
|
|
||||||
constructor(url: string | URL, protocols?: string | string[]) {
|
|
||||||
if (protocols === undefined) {
|
|
||||||
super(url);
|
|
||||||
} else {
|
|
||||||
super(url, protocols);
|
|
||||||
}
|
|
||||||
super.addEventListener("message", (event) => {
|
|
||||||
void eventDataBytes(event.data).then((bytes) => {
|
|
||||||
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
probe.outputFrames.push({
|
|
||||||
at: performance.now(),
|
|
||||||
text: frameText(bytes),
|
|
||||||
bytes: bytes.byteLength - 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
void eventDataText(event.data).then((text) => {
|
|
||||||
if (text === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
probe.textMessageFrames.push({
|
|
||||||
at: performance.now(),
|
|
||||||
bytes: new TextEncoder().encode(text).byteLength,
|
|
||||||
kind: textMessageKind(text),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
|
||||||
const bytes = bytesFrom(data);
|
|
||||||
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
|
|
||||||
probe.inputFrames.push({
|
|
||||||
at: performance.now(),
|
|
||||||
text: frameText(bytes),
|
|
||||||
bytes: bytes.byteLength - 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
super.send(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
|
|
||||||
value: NativeWebSocket.CONNECTING,
|
|
||||||
});
|
|
||||||
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
|
|
||||||
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
|
|
||||||
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
|
|
||||||
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
|
||||||
|
|
||||||
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
|
|
||||||
const getExisting = () =>
|
|
||||||
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
|
|
||||||
|
|
||||||
let terminal = getExisting();
|
|
||||||
Object.defineProperty(window, "__paseoTerminal", {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
return terminal;
|
|
||||||
},
|
|
||||||
set(next: any) {
|
|
||||||
terminal = next;
|
|
||||||
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 event: XtermWriteEvent = {
|
|
||||||
at: performance.now(),
|
|
||||||
committedAt: null,
|
|
||||||
text,
|
|
||||||
bytes: text.length,
|
|
||||||
};
|
|
||||||
probe.xtermWrites.push(event);
|
|
||||||
return originalWrite(data, () => {
|
|
||||||
event.committedAt = performance.now();
|
|
||||||
callback?.();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
next.__paseoKeystrokeProbeWriteWrapped = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
(window as any).__terminalKeystrokeStressProbe.reset();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readTerminalKeystrokeStressReport(
|
|
||||||
page: Page,
|
|
||||||
inputText: string,
|
|
||||||
): Promise<TerminalKeystrokeStressReport> {
|
|
||||||
return page.evaluate(
|
|
||||||
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
|
|
||||||
inputText,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
@@ -28,108 +28,6 @@ import {
|
|||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
|
||||||
|
|
||||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
|
||||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawMessage);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
|
||||||
const envelope = parseWebSocketJson(message);
|
|
||||||
if (!envelope || typeof envelope !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
|
||||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof maybeEnvelope.message !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return maybeEnvelope.message as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
|
||||||
const value = input[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delayBrowserAgentCreatedStatus(page: Page) {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
||||||
const createRequestIds = new Set<string>();
|
|
||||||
const delayedForwards: Array<() => void> = [];
|
|
||||||
let releaseRequested = false;
|
|
||||||
let resolveCreateRequest: (() => void) | null = null;
|
|
||||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
|
||||||
const createRequestSeen = new Promise<void>((resolve) => {
|
|
||||||
resolveCreateRequest = resolve;
|
|
||||||
});
|
|
||||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
|
||||||
resolveDelayedCreatedStatus = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
|
||||||
const server = ws.connectToServer();
|
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
|
||||||
const sessionMessage = getSessionMessage(message);
|
|
||||||
if (sessionMessage?.type === "create_agent_request") {
|
|
||||||
const requestId = getStringField(sessionMessage, "requestId");
|
|
||||||
if (requestId) {
|
|
||||||
createRequestIds.add(requestId);
|
|
||||||
resolveCreateRequest?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
server.send(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.onMessage((message) => {
|
|
||||||
const sessionMessage = getSessionMessage(message);
|
|
||||||
const payload =
|
|
||||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
|
||||||
? (sessionMessage.payload as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
|
||||||
|
|
||||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
|
||||||
resolveDelayedCreatedStatus?.();
|
|
||||||
if (releaseRequested) {
|
|
||||||
ws.send(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
delayedForwards.push(() => ws.send(message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
release() {
|
|
||||||
releaseRequested = true;
|
|
||||||
for (const forward of delayedForwards.splice(0)) {
|
|
||||||
forward();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
waitForCreateRequest: () => createRequestSeen,
|
|
||||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("New workspace flow", () => {
|
test.describe("New workspace flow", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
@@ -293,7 +191,7 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -364,87 +262,12 @@ test.describe("New workspace flow", () => {
|
|||||||
await agentTabs.first().click();
|
await agentTabs.first().click();
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
|
||||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
|
||||||
localWorkspaceIds.add(openedProject.workspaceId);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
|
||||||
page,
|
|
||||||
serverId,
|
|
||||||
targetWorkspacePath: openedProject.workspaceId,
|
|
||||||
});
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: openedProject.workspaceName,
|
|
||||||
subtitle: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, {
|
|
||||||
projectKey: openedProject.projectKey,
|
|
||||||
projectDisplayName: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createButton = page
|
|
||||||
.getByTestId("message-input-root")
|
|
||||||
.getByRole("button", { name: "Create" });
|
|
||||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await createButton.click();
|
|
||||||
|
|
||||||
await agentCreatedDelay.waitForCreateRequest();
|
|
||||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
|
||||||
|
|
||||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
|
||||||
serverId,
|
|
||||||
previousWorkspaceId: openedProject.workspaceId,
|
|
||||||
projectDisplayName: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
|
||||||
{
|
|
||||||
timeout: 30_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeWorkspaceDeckEntry = page
|
|
||||||
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
|
||||||
.filter({ visible: true });
|
|
||||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
|
||||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
await expect(
|
|
||||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
agentCreatedDelay.release();
|
|
||||||
await expect(
|
|
||||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
|
||||||
).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
} finally {
|
|
||||||
agentCreatedDelay.release();
|
|
||||||
await tempRepo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { expect, test, type Page } from "./fixtures";
|
|
||||||
import {
|
|
||||||
archiveAgentFromDaemon,
|
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
|
||||||
openWorkspaceWithAgents,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pressSettingsToggleShortcut(page: Page) {
|
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
||||||
await page.keyboard.press(`${modifier}+Comma`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
|
|
||||||
})
|
|
||||||
.toBe(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAgentRouteAndExpectFocused(input: {
|
|
||||||
page: Page;
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
agentId: string;
|
|
||||||
}) {
|
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
|
||||||
await input.page.goto(
|
|
||||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
|
||||||
);
|
|
||||||
await input.page.waitForURL(
|
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await waitForTabBar(input.page);
|
|
||||||
await expect(
|
|
||||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Settings toggle tab regression", () => {
|
|
||||||
test.describe.configure({ timeout: 180_000 });
|
|
||||||
|
|
||||||
test("toggling settings after changing a setting returns to the same workspace tab", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = getServerId();
|
|
||||||
const client = await connectArchiveTabDaemonClient();
|
|
||||||
const repo = await createTempGitRepo("settings-toggle-tab-");
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstAgent = await createIdleAgent(client, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `settings-toggle-a-${Date.now()}`,
|
|
||||||
});
|
|
||||||
const secondAgent = await createIdleAgent(client, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `settings-toggle-b-${Date.now()}`,
|
|
||||||
});
|
|
||||||
agentIds.push(firstAgent.id, secondAgent.id);
|
|
||||||
|
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
|
||||||
await waitForTabBar(page);
|
|
||||||
|
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
|
||||||
await expectSendBehavior(page, "queue");
|
|
||||||
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
|
||||||
await expectSendBehavior(page, "interrupt");
|
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
|
||||||
await waitForTabBar(page);
|
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
} finally {
|
|
||||||
for (const agentId of agentIds) {
|
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = getServerId();
|
|
||||||
const client = await connectArchiveTabDaemonClient();
|
|
||||||
const repo = await createTempGitRepo("agent-route-refresh-");
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstAgent = await createIdleAgent(client, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `agent-route-refresh-a-${Date.now()}`,
|
|
||||||
});
|
|
||||||
const secondAgent = await createIdleAgent(client, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `agent-route-refresh-b-${Date.now()}`,
|
|
||||||
});
|
|
||||||
agentIds.push(firstAgent.id, secondAgent.id);
|
|
||||||
|
|
||||||
await openAgentRouteAndExpectFocused({
|
|
||||||
page,
|
|
||||||
serverId,
|
|
||||||
workspaceId: repo.path,
|
|
||||||
agentId: firstAgent.id,
|
|
||||||
});
|
|
||||||
await openAgentRouteAndExpectFocused({
|
|
||||||
page,
|
|
||||||
serverId,
|
|
||||||
workspaceId: repo.path,
|
|
||||||
agentId: secondAgent.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
||||||
await page.reload();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
await expect(
|
|
||||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
for (const agentId of agentIds) {
|
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { test } from "./fixtures";
|
|
||||||
import { startupScenario } from "./helpers/startup-dsl";
|
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getE2EServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Startup loading presentation", () => {
|
|
||||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
|
||||||
const startup = await startupScenario(page)
|
|
||||||
.withMobileViewport()
|
|
||||||
.withSavedHost({
|
|
||||||
serverId: "srv_unreachable_mobile",
|
|
||||||
label: "Dev",
|
|
||||||
endpoint: "127.0.0.1:45678",
|
|
||||||
})
|
|
||||||
.openRoot();
|
|
||||||
|
|
||||||
await startup.expectsReconnectWelcome();
|
|
||||||
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
|
||||||
await startup.expectsNoLocalServerStartupCopy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const startup = await startupScenario(page)
|
|
||||||
.withPendingDesktopDaemon()
|
|
||||||
.withBlockedPort(getE2EDaemonPort())
|
|
||||||
.openRoot();
|
|
||||||
|
|
||||||
await startup.expectsDesktopDaemonStartup();
|
|
||||||
await startup.expectsSidebarHidden();
|
|
||||||
await startup.expectsNoUndefinedRoute();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = getE2EServerId();
|
|
||||||
const startup = await startupScenario(page)
|
|
||||||
.withPendingDesktopDaemon()
|
|
||||||
.withBlockedPort(getE2EDaemonPort())
|
|
||||||
.openHostWorkspace({
|
|
||||||
serverId,
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
await startup.expectsDesktopDaemonStartup();
|
|
||||||
await startup.expectsSidebarHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
import { Buffer } from "node:buffer";
|
|
||||||
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
type WireDirection = "sent" | "received";
|
|
||||||
type WirePhase = "startup" | "workspace_clicks";
|
|
||||||
|
|
||||||
type ParsedWireMessage = {
|
|
||||||
type: string | null;
|
|
||||||
requestId: string | null;
|
|
||||||
entryCount: number | null;
|
|
||||||
hasMore: boolean | null;
|
|
||||||
providerEntries: ProviderSnapshotWireEntry[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WireFrameRecord = ParsedWireMessage & {
|
|
||||||
phase: WirePhase;
|
|
||||||
direction: WireDirection;
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProviderSnapshotWireEntry = {
|
|
||||||
provider: string;
|
|
||||||
status: string | null;
|
|
||||||
modelCount: number;
|
|
||||||
modeCount: number;
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WebSocketFrameEvent = {
|
|
||||||
requestId: string;
|
|
||||||
response: {
|
|
||||||
opcode: number;
|
|
||||||
payloadData: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type WireSummary = {
|
|
||||||
totalFrames: number;
|
|
||||||
totalBytes: number;
|
|
||||||
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
|
||||||
byPhase: Record<
|
|
||||||
WirePhase,
|
|
||||||
{
|
|
||||||
frames: number;
|
|
||||||
bytes: number;
|
|
||||||
byType: Array<{ type: string; frames: number; bytes: number }>;
|
|
||||||
rpcCounts: Array<{ requestType: string; count: number }>;
|
|
||||||
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
fetchPages: Array<{
|
|
||||||
phase: WirePhase;
|
|
||||||
type: string;
|
|
||||||
requestId: string | null;
|
|
||||||
entries: number | null;
|
|
||||||
hasMore: boolean | null;
|
|
||||||
bytes: number;
|
|
||||||
}>;
|
|
||||||
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
|
||||||
providerSnapshots: Array<{
|
|
||||||
phase: WirePhase;
|
|
||||||
type: string;
|
|
||||||
requestId: string | null;
|
|
||||||
totalModels: number;
|
|
||||||
totalModes: number;
|
|
||||||
bytes: number;
|
|
||||||
providers: ProviderSnapshotWireEntry[];
|
|
||||||
}>;
|
|
||||||
providerSnapshotTotals: Array<{
|
|
||||||
phase: WirePhase;
|
|
||||||
provider: string;
|
|
||||||
frames: number;
|
|
||||||
bytes: number;
|
|
||||||
maxModels: number;
|
|
||||||
maxModes: number;
|
|
||||||
statuses: string[];
|
|
||||||
}>;
|
|
||||||
fork: {
|
|
||||||
sourceHome: string | null;
|
|
||||||
targetHome: string | null;
|
|
||||||
copiedFiles: number | null;
|
|
||||||
copiedBytes: number | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
class WireMonitor {
|
|
||||||
private phase: WirePhase = "startup";
|
|
||||||
private session: CDPSession | null = null;
|
|
||||||
readonly records: WireFrameRecord[] = [];
|
|
||||||
|
|
||||||
async start(page: Page): Promise<void> {
|
|
||||||
this.session = await page.context().newCDPSession(page);
|
|
||||||
await this.session.send("Network.enable");
|
|
||||||
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
|
||||||
this.record("sent", event);
|
|
||||||
});
|
|
||||||
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
|
||||||
this.record("received", event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setPhase(phase: WirePhase): void {
|
|
||||||
this.phase = phase;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCompletedStartupFetches(): boolean {
|
|
||||||
return (
|
|
||||||
this.records.some(
|
|
||||||
(record) =>
|
|
||||||
record.direction === "received" &&
|
|
||||||
record.type === "fetch_agents_response" &&
|
|
||||||
record.hasMore === false,
|
|
||||||
) &&
|
|
||||||
this.records.some(
|
|
||||||
(record) =>
|
|
||||||
record.direction === "received" &&
|
|
||||||
record.type === "fetch_workspaces_response" &&
|
|
||||||
record.hasMore === false,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
|
||||||
return {
|
|
||||||
totalFrames: this.records.length,
|
|
||||||
totalBytes: sumBytes(this.records),
|
|
||||||
byDirection: {
|
|
||||||
sent: summarizeDirection(this.records, "sent"),
|
|
||||||
received: summarizeDirection(this.records, "received"),
|
|
||||||
},
|
|
||||||
byPhase: {
|
|
||||||
startup: summarizePhase(this.records, "startup"),
|
|
||||||
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
|
||||||
},
|
|
||||||
fetchPages: this.records
|
|
||||||
.filter(
|
|
||||||
(record) =>
|
|
||||||
record.direction === "received" &&
|
|
||||||
(record.type === "fetch_agents_response" ||
|
|
||||||
record.type === "fetch_workspaces_response"),
|
|
||||||
)
|
|
||||||
.map((record) => ({
|
|
||||||
phase: record.phase,
|
|
||||||
type: record.type ?? "unknown",
|
|
||||||
requestId: record.requestId,
|
|
||||||
entries: record.entryCount,
|
|
||||||
hasMore: record.hasMore,
|
|
||||||
bytes: record.bytes,
|
|
||||||
})),
|
|
||||||
clickedWorkspaces,
|
|
||||||
providerSnapshots: this.records
|
|
||||||
.filter((record) => record.direction === "received" && record.providerEntries)
|
|
||||||
.map((record) => ({
|
|
||||||
phase: record.phase,
|
|
||||||
type: record.type ?? "unknown",
|
|
||||||
requestId: record.requestId,
|
|
||||||
totalModels: sumProviderModels(record.providerEntries ?? []),
|
|
||||||
totalModes: sumProviderModes(record.providerEntries ?? []),
|
|
||||||
bytes: record.bytes,
|
|
||||||
providers: record.providerEntries ?? [],
|
|
||||||
})),
|
|
||||||
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
|
||||||
fork: {
|
|
||||||
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
|
||||||
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
|
||||||
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
|
||||||
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
|
||||||
if (event.response.opcode !== 1) {
|
|
||||||
this.records.push({
|
|
||||||
phase: this.phase,
|
|
||||||
direction,
|
|
||||||
bytes: Buffer.byteLength(event.response.payloadData),
|
|
||||||
type: `opcode:${event.response.opcode}`,
|
|
||||||
requestId: null,
|
|
||||||
entryCount: null,
|
|
||||||
hasMore: null,
|
|
||||||
providerEntries: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.records.push({
|
|
||||||
phase: this.phase,
|
|
||||||
direction,
|
|
||||||
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
|
||||||
...parseWireMessage(event.response.payloadData),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalNumber(value: string | undefined): number | null {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payloadData) as unknown;
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
return emptyParsedWireMessage();
|
|
||||||
}
|
|
||||||
const envelope = parsed as {
|
|
||||||
type?: unknown;
|
|
||||||
message?: unknown;
|
|
||||||
};
|
|
||||||
const unwrapped =
|
|
||||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
|
||||||
? envelope.message
|
|
||||||
: parsed;
|
|
||||||
const message = unwrapped as {
|
|
||||||
type?: unknown;
|
|
||||||
requestId?: unknown;
|
|
||||||
payload?: {
|
|
||||||
requestId?: unknown;
|
|
||||||
entries?: unknown;
|
|
||||||
agents?: unknown;
|
|
||||||
pageInfo?: { hasMore?: unknown };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const entries = readMessageEntries(message.payload);
|
|
||||||
const hasMore = readMessageHasMore(message.payload);
|
|
||||||
const messageType = typeof message.type === "string" ? message.type : null;
|
|
||||||
const providerEntries = isProviderSnapshotMessage(messageType)
|
|
||||||
? parseProviderSnapshotEntries(message.payload)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
type: messageType,
|
|
||||||
requestId: readMessageRequestId(message),
|
|
||||||
entryCount: entries ? entries.length : null,
|
|
||||||
hasMore,
|
|
||||||
providerEntries,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return emptyParsedWireMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMessageRequestId(message: {
|
|
||||||
requestId?: unknown;
|
|
||||||
payload?: { requestId?: unknown };
|
|
||||||
}): string | null {
|
|
||||||
if (typeof message.requestId === "string") {
|
|
||||||
return message.requestId;
|
|
||||||
}
|
|
||||||
if (typeof message.payload?.requestId === "string") {
|
|
||||||
return message.payload.requestId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
|
||||||
return (
|
|
||||||
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMessageEntries(
|
|
||||||
payload:
|
|
||||||
| {
|
|
||||||
entries?: unknown;
|
|
||||||
agents?: unknown;
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
): unknown[] | null {
|
|
||||||
if (Array.isArray(payload?.entries)) {
|
|
||||||
return payload.entries;
|
|
||||||
}
|
|
||||||
if (Array.isArray(payload?.agents)) {
|
|
||||||
return payload.agents;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMessageHasMore(
|
|
||||||
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
|
||||||
): boolean | null {
|
|
||||||
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyParsedWireMessage(): ParsedWireMessage {
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
requestId: null,
|
|
||||||
entryCount: null,
|
|
||||||
hasMore: null,
|
|
||||||
providerEntries: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
|
||||||
if (!payload || typeof payload !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entries = (payload as { entries?: unknown }).entries;
|
|
||||||
if (!Array.isArray(entries)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry || typeof entry !== "object") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const provider = (entry as { provider?: unknown }).provider;
|
|
||||||
if (typeof provider !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const models = (entry as { models?: unknown }).models;
|
|
||||||
const modes = (entry as { modes?: unknown }).modes;
|
|
||||||
const status = (entry as { status?: unknown }).status;
|
|
||||||
providerEntries.push({
|
|
||||||
provider,
|
|
||||||
status: typeof status === "string" ? status : null,
|
|
||||||
modelCount: Array.isArray(models) ? models.length : 0,
|
|
||||||
modeCount: Array.isArray(modes) ? modes.length : 0,
|
|
||||||
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return providerEntries.length > 0 ? providerEntries : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumBytes(records: WireFrameRecord[]): number {
|
|
||||||
return records.reduce((sum, record) => sum + record.bytes, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
|
||||||
const selected = records.filter((record) => record.direction === direction);
|
|
||||||
return {
|
|
||||||
frames: selected.length,
|
|
||||||
bytes: sumBytes(selected),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
|
||||||
const selected = records.filter((record) => record.phase === phase);
|
|
||||||
return {
|
|
||||||
frames: selected.length,
|
|
||||||
bytes: sumBytes(selected),
|
|
||||||
byType: summarizeByType(selected),
|
|
||||||
rpcCounts: summarizeRpcCounts(selected),
|
|
||||||
rpcs: summarizeRpcs(selected),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeByType(records: WireFrameRecord[]): Array<{
|
|
||||||
type: string;
|
|
||||||
frames: number;
|
|
||||||
bytes: number;
|
|
||||||
}> {
|
|
||||||
const byType = new Map<string, { frames: number; bytes: number }>();
|
|
||||||
for (const record of records) {
|
|
||||||
const type = record.type ?? "unknown";
|
|
||||||
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
|
||||||
current.frames += 1;
|
|
||||||
current.bytes += record.bytes;
|
|
||||||
byType.set(type, current);
|
|
||||||
}
|
|
||||||
return [...byType.entries()]
|
|
||||||
.map(([type, value]) => ({ type, ...value }))
|
|
||||||
.sort((left, right) => right.bytes - left.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
|
||||||
const responsesByRequestId = new Map<string, string>();
|
|
||||||
for (const record of records) {
|
|
||||||
if (record.direction !== "received" || !record.requestId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
return records
|
|
||||||
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
|
||||||
.map((record) => ({
|
|
||||||
requestType: record.type ?? "unknown",
|
|
||||||
requestId: record.requestId ?? "",
|
|
||||||
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeRpcCounts(
|
|
||||||
records: WireFrameRecord[],
|
|
||||||
): Array<{ requestType: string; count: number }> {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const record of records) {
|
|
||||||
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return [...counts.entries()]
|
|
||||||
.map(([requestType, count]) => ({ requestType, count }))
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
|
||||||
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
|
||||||
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeProviderSnapshots(
|
|
||||||
records: WireFrameRecord[],
|
|
||||||
): WireSummary["providerSnapshotTotals"] {
|
|
||||||
const byProvider = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
phase: WirePhase;
|
|
||||||
provider: string;
|
|
||||||
frames: number;
|
|
||||||
bytes: number;
|
|
||||||
maxModels: number;
|
|
||||||
maxModes: number;
|
|
||||||
statuses: Set<string>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
if (record.direction !== "received" || !record.providerEntries) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of record.providerEntries) {
|
|
||||||
const key = `${record.phase}:${entry.provider}`;
|
|
||||||
const current = byProvider.get(key) ?? {
|
|
||||||
phase: record.phase,
|
|
||||||
provider: entry.provider,
|
|
||||||
frames: 0,
|
|
||||||
bytes: 0,
|
|
||||||
maxModels: 0,
|
|
||||||
maxModes: 0,
|
|
||||||
statuses: new Set<string>(),
|
|
||||||
};
|
|
||||||
current.frames += 1;
|
|
||||||
current.bytes += entry.bytes;
|
|
||||||
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
|
||||||
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
|
||||||
if (entry.status) {
|
|
||||||
current.statuses.add(entry.status);
|
|
||||||
}
|
|
||||||
byProvider.set(key, current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byProvider.values()]
|
|
||||||
.map((entry) => ({
|
|
||||||
phase: entry.phase,
|
|
||||||
provider: entry.provider,
|
|
||||||
frames: entry.frames,
|
|
||||||
bytes: entry.bytes,
|
|
||||||
maxModels: entry.maxModels,
|
|
||||||
maxModes: entry.maxModes,
|
|
||||||
statuses: [...entry.statuses].sort(),
|
|
||||||
}))
|
|
||||||
.sort((left, right) => right.bytes - left.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
|
||||||
await testInfo.attach("startup-wire-metrics.json", {
|
|
||||||
body: JSON.stringify(summary, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("ad hoc startup wire metrics", () => {
|
|
||||||
test.skip(
|
|
||||||
process.env.E2E_WIRE_METRICS !== "1",
|
|
||||||
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
|
|
||||||
const monitor = new WireMonitor();
|
|
||||||
await monitor.start(page);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page, 120_000);
|
|
||||||
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
|
|
||||||
const workspaceTestIds = await page
|
|
||||||
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
|
||||||
.evaluateAll((elements) =>
|
|
||||||
elements
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((element) => element.getAttribute("data-testid"))
|
|
||||||
.filter((value): value is string => Boolean(value)),
|
|
||||||
);
|
|
||||||
|
|
||||||
monitor.setPhase("workspace_clicks");
|
|
||||||
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
|
||||||
for (const testId of workspaceTestIds) {
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
if (!(await row.isVisible().catch(() => false))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const beforeFrames = monitor.records.length;
|
|
||||||
const beforeBytes = sumBytes(monitor.records);
|
|
||||||
await row.click();
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
clickedWorkspaces.push({
|
|
||||||
testId,
|
|
||||||
frames: monitor.records.length - beforeFrames,
|
|
||||||
bytes: sumBytes(monitor.records) - beforeBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = monitor.summarize(clickedWorkspaces);
|
|
||||||
await attachSummary(testInfo, summary);
|
|
||||||
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
|
||||||
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
|
||||||
|
|
||||||
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
|
||||||
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
|
||||||
import {
|
|
||||||
installTerminalRenderProbe,
|
|
||||||
readTerminalRenderProbe,
|
|
||||||
resetTerminalRenderProbe,
|
|
||||||
startTerminalFrameSampling,
|
|
||||||
summarizeTerminalRenderProbe,
|
|
||||||
} from "./helpers/terminal-probes";
|
|
||||||
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
|
||||||
|
|
||||||
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
|
|
||||||
let lastBufferText = "";
|
|
||||||
let lastProbe = await readTerminalRenderProbe(page);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
lastBufferText = await getTerminalBufferText(page);
|
|
||||||
lastProbe = await readTerminalRenderProbe(page);
|
|
||||||
return (
|
|
||||||
lastProbe.altEnterWrites > 0 &&
|
|
||||||
lastProbe.altExitWrites > 0 &&
|
|
||||||
lastBufferText.includes(afterAlt)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
intervals: [50],
|
|
||||||
message: `wait for alternate-screen exit and ${afterAlt} output`,
|
|
||||||
timeout,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.toBe(true);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(
|
|
||||||
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
|
|
||||||
{
|
|
||||||
afterAlt,
|
|
||||||
probe: summarizeTerminalRenderProbe(lastProbe),
|
|
||||||
bufferTextTail: lastBufferText.slice(-500),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastProbe;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Terminal alternate-screen transitions", () => {
|
|
||||||
let harness: TerminalE2EHarness;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await harness?.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(60_000);
|
|
||||||
|
|
||||||
await installTerminalRenderProbe(page);
|
|
||||||
|
|
||||||
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
|
|
||||||
await harness.setupPrompt(page);
|
|
||||||
|
|
||||||
const terminal = harness.terminalSurface(page);
|
|
||||||
const historyReady = `HISTORY_READY_${Date.now()}`;
|
|
||||||
await terminal.pressSequentially(
|
|
||||||
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
|
|
||||||
{ delay: 0 },
|
|
||||||
);
|
|
||||||
await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
|
|
||||||
|
|
||||||
await resetTerminalRenderProbe(page);
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const settledProbe = await readTerminalRenderProbe(page);
|
|
||||||
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
await resetTerminalRenderProbe(page);
|
|
||||||
|
|
||||||
const afterAlt = `AFTER_ALT_${Date.now()}`;
|
|
||||||
await startTerminalFrameSampling(page);
|
|
||||||
await terminal.pressSequentially(
|
|
||||||
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
|
|
||||||
{ delay: 0 },
|
|
||||||
);
|
|
||||||
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
|
|
||||||
const probeSummary = summarizeTerminalRenderProbe(probe);
|
|
||||||
|
|
||||||
await testInfo.attach("alternate-screen-probe", {
|
|
||||||
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
|
|
||||||
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
|
|
||||||
expect(
|
|
||||||
probe.altEnterWrites,
|
|
||||||
"test command should enter the alternate screen",
|
|
||||||
).toBeGreaterThan(0);
|
|
||||||
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
|
|
||||||
|
|
||||||
const finalBufferText = await getTerminalBufferText(page);
|
|
||||||
expect(finalBufferText).toContain(historyReady);
|
|
||||||
expect(finalBufferText).toContain(afterAlt);
|
|
||||||
|
|
||||||
const suspiciousFrames = probe.frames.filter(
|
|
||||||
(frame) =>
|
|
||||||
frame.text.includes("$") &&
|
|
||||||
!frame.text.includes(historyReady) &&
|
|
||||||
!frame.text.includes(afterAlt) &&
|
|
||||||
frame.nonEmptyRows <= 2 &&
|
|
||||||
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
suspiciousFrames,
|
|
||||||
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
|
||||||
import type { Page, TestInfo } from "@playwright/test";
|
|
||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
|
|
||||||
import {
|
|
||||||
installTerminalKeystrokeStressProbe,
|
|
||||||
readTerminalKeystrokeStressReport,
|
|
||||||
resetTerminalKeystrokeStressProbe,
|
|
||||||
type LatencyStats,
|
|
||||||
} from "./helpers/terminal-probes";
|
|
||||||
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
|
||||||
|
|
||||||
const INPUT_TEXT = buildStressText(600);
|
|
||||||
const BIG_DIFF_BYTES = 256_000;
|
|
||||||
const SMALL_AGENT_STREAM_UPDATES = 1000;
|
|
||||||
const STRESS_TIMEOUT_MS = 15_000;
|
|
||||||
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
|
||||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
|
||||||
|
|
||||||
type DaemonEchoReport = {
|
|
||||||
inputTextLength: number;
|
|
||||||
inputFrameCount: number;
|
|
||||||
outputEventCount: number;
|
|
||||||
echoedBytes: number;
|
|
||||||
sendToOutputMs: LatencyStats;
|
|
||||||
firstSendAt: number;
|
|
||||||
lastOutputAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
terminalPerfDescribe("Terminal keystroke stress", () => {
|
|
||||||
let harness: TerminalE2EHarness;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await harness?.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs daemon-only and app keystroke echo latency under burst input", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(75_000);
|
|
||||||
|
|
||||||
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
|
|
||||||
try {
|
|
||||||
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
|
|
||||||
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
|
|
||||||
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
|
|
||||||
|
|
||||||
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
} finally {
|
|
||||||
await harness.killTerminal(daemonTerminal.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await installTerminalKeystrokeStressProbe(page);
|
|
||||||
const appBaselineReport = await measureAppBurstEcho({
|
|
||||||
page,
|
|
||||||
harness,
|
|
||||||
terminalName: "app-keystroke-stress",
|
|
||||||
});
|
|
||||||
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
|
|
||||||
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
|
|
||||||
|
|
||||||
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const appSmallChunksReport = await measureAppBurstEcho({
|
|
||||||
page,
|
|
||||||
harness,
|
|
||||||
terminalName: "app-keystroke-stress-small-agent-chunks",
|
|
||||||
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
|
|
||||||
});
|
|
||||||
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
|
|
||||||
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
|
|
||||||
|
|
||||||
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
|
||||||
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
|
|
||||||
SMALL_AGENT_STREAM_UPDATES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appBigDiffReport = await measureAppBurstEcho({
|
|
||||||
page,
|
|
||||||
harness,
|
|
||||||
terminalName: "app-keystroke-stress-big-diff",
|
|
||||||
bigDiffBytes: BIG_DIFF_BYTES,
|
|
||||||
});
|
|
||||||
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
|
|
||||||
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
|
|
||||||
|
|
||||||
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
|
||||||
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
|
||||||
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
|
|
||||||
BIG_DIFF_BYTES,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function measureAppBurstEcho(input: {
|
|
||||||
page: Page;
|
|
||||||
harness: TerminalE2EHarness;
|
|
||||||
terminalName: string;
|
|
||||||
bigDiffBytes?: number;
|
|
||||||
agentStreamUpdateCount?: number;
|
|
||||||
}) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
const agent =
|
|
||||||
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
|
|
||||||
? null
|
|
||||||
: await input.harness.client.createAgent({
|
|
||||||
provider: "mock",
|
|
||||||
cwd: input.harness.tempRepo.path,
|
|
||||||
title: "Large WebSocket payload",
|
|
||||||
modeId: "load-test",
|
|
||||||
});
|
|
||||||
|
|
||||||
const bigDiffBytes = input.bigDiffBytes;
|
|
||||||
const agentStreamUpdateCount = input.agentStreamUpdateCount;
|
|
||||||
const startAgentLoad =
|
|
||||||
agent === null
|
|
||||||
? null
|
|
||||||
: () => {
|
|
||||||
if (bigDiffBytes !== undefined) {
|
|
||||||
return emitLargeDiffAgentPayload(input.harness, {
|
|
||||||
agentId: agent.id,
|
|
||||||
bytes: bigDiffBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (agentStreamUpdateCount !== undefined) {
|
|
||||||
return emitRapidAgentStreamUpdates(input.harness, {
|
|
||||||
agentId: agent.id,
|
|
||||||
count: agentStreamUpdateCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
const terminal = input.harness.terminalSurface(input.page);
|
|
||||||
await terminal.press("Control+c");
|
|
||||||
await input.page.waitForTimeout(200);
|
|
||||||
await resetTerminalKeystrokeStressProbe(input.page);
|
|
||||||
|
|
||||||
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
|
|
||||||
|
|
||||||
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
|
|
||||||
await waitForAppStressEcho(input.page, INPUT_TEXT);
|
|
||||||
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
|
|
||||||
if (input.bigDiffBytes !== undefined) {
|
|
||||||
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
|
|
||||||
}
|
|
||||||
if (input.agentStreamUpdateCount !== undefined) {
|
|
||||||
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
|
|
||||||
}
|
|
||||||
await activeAgentLoadPromise;
|
|
||||||
|
|
||||||
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
|
|
||||||
} finally {
|
|
||||||
await input.harness.killTerminal(appTerminal.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emitRapidAgentStreamUpdates(
|
|
||||||
harness: TerminalE2EHarness,
|
|
||||||
input: { agentId: string; count: number },
|
|
||||||
): Promise<void> {
|
|
||||||
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emitLargeDiffAgentPayload(
|
|
||||||
harness: TerminalE2EHarness,
|
|
||||||
input: { agentId: string; bytes: number },
|
|
||||||
): Promise<void> {
|
|
||||||
await harness.client.sendAgentMessage(
|
|
||||||
input.agentId,
|
|
||||||
`emit ${input.bytes} byte large diff agent stream update`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureDaemonBurstEcho(
|
|
||||||
harness: TerminalE2EHarness,
|
|
||||||
terminal: TerminalInstance,
|
|
||||||
inputText: string,
|
|
||||||
): Promise<DaemonEchoReport> {
|
|
||||||
await harness.client.subscribeTerminal(terminal.id);
|
|
||||||
|
|
||||||
const outputTimesByByte: number[] = [];
|
|
||||||
let outputEventCount = 0;
|
|
||||||
let echoedBytes = 0;
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
|
|
||||||
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
outputEventCount += 1;
|
|
||||||
const text = decoder.decode(event.data);
|
|
||||||
const now = performance.now();
|
|
||||||
const previousEchoedBytes = echoedBytes;
|
|
||||||
echoedBytes += text.length;
|
|
||||||
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
|
|
||||||
outputTimesByByte[index] = now;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
echoedBytes = 0;
|
|
||||||
outputTimesByByte.length = 0;
|
|
||||||
|
|
||||||
const sendTimes: number[] = [];
|
|
||||||
for (const char of inputText) {
|
|
||||||
sendTimes.push(performance.now());
|
|
||||||
harness.client.sendTerminalInput(terminal.id, {
|
|
||||||
type: "input",
|
|
||||||
data: char,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForDaemonEchoBytes({
|
|
||||||
getEchoedBytes: () => echoedBytes,
|
|
||||||
expectedBytes: inputText.length,
|
|
||||||
timeoutMs: STRESS_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
|
||||||
return {
|
|
||||||
inputTextLength: inputText.length,
|
|
||||||
inputFrameCount: sendTimes.length,
|
|
||||||
outputEventCount,
|
|
||||||
echoedBytes,
|
|
||||||
sendToOutputMs: summarizeLatency(latencies),
|
|
||||||
firstSendAt: sendTimes[0] ?? 0,
|
|
||||||
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForDaemonEchoBytes(input: {
|
|
||||||
getEchoedBytes: () => number;
|
|
||||||
expectedBytes: number;
|
|
||||||
timeoutMs: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
const deadline = Date.now() + input.timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (input.getEchoedBytes() >= input.expectedBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
|
|
||||||
const tail = text.slice(-80);
|
|
||||||
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
|
||||||
if (report.outputFramePayloadBytes >= expectedBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
|
||||||
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(25);
|
|
||||||
}
|
|
||||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
|
||||||
throw new Error(
|
|
||||||
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
|
||||||
if (report.agentStreamTextMessageCount >= expectedCount) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(25);
|
|
||||||
}
|
|
||||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
|
||||||
throw new Error(
|
|
||||||
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
|
|
||||||
await testInfo.attach(name, {
|
|
||||||
body: JSON.stringify(value, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStressText(length: number): string {
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
||||||
let output = "";
|
|
||||||
while (output.length < length) {
|
|
||||||
output += alphabet;
|
|
||||||
}
|
|
||||||
return output.slice(0, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeLatency(values: number[]): LatencyStats {
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const percentile = (p: number) => {
|
|
||||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
|
||||||
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
|
|
||||||
};
|
|
||||||
const total = values.reduce((sum, value) => sum + value, 0);
|
|
||||||
return {
|
|
||||||
count: values.length,
|
|
||||||
minMs: round2(sorted[0] ?? 0),
|
|
||||||
p50Ms: round2(percentile(50)),
|
|
||||||
p95Ms: round2(percentile(95)),
|
|
||||||
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
|
||||||
avgMs: round2(total / values.length),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function round2(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100;
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,8 @@ const LINE_COUNT = 50_000;
|
|||||||
const THROUGHPUT_BUDGET_MS = 30_000;
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
const KEYSTROKE_SAMPLE_COUNT = 20;
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
const KEYSTROKE_P95_BUDGET_MS = 150;
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
|
||||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
|
||||||
|
|
||||||
terminalPerfDescribe("Terminal wire performance", () => {
|
test.describe("Terminal wire performance", () => {
|
||||||
let client: TerminalPerfDaemonClient;
|
let client: TerminalPerfDaemonClient;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
let workspaceId: string;
|
let workspaceId: string;
|
||||||
|
|||||||
@@ -139,35 +139,6 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ agentId, serverId }) => {
|
|
||||||
globalThis.dispatchEvent(
|
|
||||||
new CustomEvent("paseo:web-notification-click", {
|
|
||||||
detail: {
|
|
||||||
data: {
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
reason: "finished",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ agentId: secondAgent.id, serverId },
|
|
||||||
);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
|
||||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
|
||||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
|
||||||
await expect(firstDeckEntry).toBeAttached();
|
|
||||||
await expect(firstDeckEntry).toBeHidden();
|
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceScriptStarter = {
|
|
||||||
startWorkspaceScript(
|
|
||||||
workspaceId: string,
|
|
||||||
scriptName: string,
|
|
||||||
): Promise<{
|
|
||||||
workspaceId: string;
|
|
||||||
scriptName: string;
|
|
||||||
terminalId: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||||
async function navigateToWorkspaceViaSidebar(
|
async function navigateToWorkspaceViaSidebar(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
@@ -238,7 +226,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
|
test("launches script terminals after setup completes", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||||
@@ -275,15 +263,12 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
// Wait for the script terminal tab to appear in the tabs bar.
|
||||||
await page.getByTestId("workspace-scripts-button").click();
|
// The tab title shows the command, not the script name.
|
||||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
|
||||||
await page.getByTestId("workspace-scripts-start-web").click();
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Click the script terminal tab
|
||||||
await terminalTab.click();
|
await terminalTab.click();
|
||||||
|
|
||||||
// Verify the terminal surface rendered
|
// Verify the terminal surface rendered
|
||||||
@@ -309,7 +294,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
test("launches workspace scripts after setup completes", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -318,7 +303,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
},
|
},
|
||||||
scripts: {
|
scripts: {
|
||||||
editor: {
|
editor: {
|
||||||
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
|
command: "npm run dev",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -340,19 +325,9 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
throw new Error(result.error ?? "Failed to create workspace");
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
}
|
}
|
||||||
const workspaceDir = result.workspace.workspaceDirectory;
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
const workspaceId = String(result.workspace.id);
|
|
||||||
|
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
const scriptClient = client as typeof client & WorkspaceScriptStarter;
|
|
||||||
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
|
|
||||||
expect(startResult).toMatchObject({
|
|
||||||
workspaceId,
|
|
||||||
scriptName: "editor",
|
|
||||||
terminalId: expect.any(String),
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const terminals = await client.listTerminals(workspaceDir);
|
const terminals = await client.listTerminals(workspaceDir);
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Maestro Flows
|
|
||||||
|
|
||||||
This directory contains local mobile UI flows. Keep flows small enough that a
|
|
||||||
failure screenshot proves the intended behavior, not just that the app launched.
|
|
||||||
|
|
||||||
## New Workspace Android Flow
|
|
||||||
|
|
||||||
Use these files when debugging or extending workspace creation on Android:
|
|
||||||
|
|
||||||
- `test-workspace-create-android-crash.sh` runs the full regression harness.
|
|
||||||
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
|
|
||||||
harness.
|
|
||||||
- `record-workspace-create-android-focus.sh` records only the focused repro
|
|
||||||
window after setup.
|
|
||||||
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
|
|
||||||
sidebar open and a prepared project visible.
|
|
||||||
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
|
|
||||||
and performs the actual workspace creation.
|
|
||||||
|
|
||||||
The reusable pieces live in `flows/`:
|
|
||||||
|
|
||||||
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
|
|
||||||
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
|
|
||||||
the welcome screen is visible.
|
|
||||||
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
|
|
||||||
the compact Android sidebar, and waits for the prepared project.
|
|
||||||
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
|
|
||||||
new-workspace action and waits for `/new`.
|
|
||||||
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
|
|
||||||
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
|
|
||||||
the app landed on the created workspace.
|
|
||||||
|
|
||||||
Compose new workspace scenarios out of these primitives instead of copying the
|
|
||||||
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
|
|
||||||
file into the same temp directory, so nested `runFlow: flows/...` paths keep
|
|
||||||
working with `${PASEO_MAESTRO_*}` placeholders.
|
|
||||||
|
|
||||||
The flow is intentionally strict. It must:
|
|
||||||
|
|
||||||
1. Open a prepared project from the daemon.
|
|
||||||
2. Tap the project row's new-workspace action.
|
|
||||||
3. Select an actual provider/model before tapping `Create`.
|
|
||||||
4. Tap `Create`.
|
|
||||||
5. Assert the app lands on a workspace header and the draft composer.
|
|
||||||
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
|
|
||||||
visible.
|
|
||||||
7. For the shell harness, grep logcat for `failed to insert view` and
|
|
||||||
`specified child already has a parent`.
|
|
||||||
|
|
||||||
Do not weaken this flow to only wait for `message-input-root`. That can pass on
|
|
||||||
the wrong route. The header assertion and the `New workspace` negative assertion
|
|
||||||
are what prove the redirect actually completed.
|
|
||||||
|
|
||||||
The scripts assume a development build with package id `sh.paseo.debug`, an
|
|
||||||
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
|
|
||||||
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
|
|
||||||
daemon.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
|
||||||
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
|
||||||
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
|
||||||
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
|
||||||
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Handle Expo dev launcher/dev menu screens when testing Android development
|
|
||||||
# builds. Release builds no-op through these optional/conditional steps.
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible: "DEVELOPMENT SERVERS"
|
|
||||||
commands:
|
|
||||||
# Expo dev launcher text matching is flaky on Android here; tap the first
|
|
||||||
# development server row by coordinate as the fallback.
|
|
||||||
- tapOn:
|
|
||||||
point: "50%,8%"
|
|
||||||
|
|
||||||
- waitForAnimationToEnd
|
|
||||||
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible: "Continue"
|
|
||||||
commands:
|
|
||||||
- tapOn: "Continue"
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
text: "Continue"
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
- waitForAnimationToEnd
|
|
||||||
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible: "Performance monitor"
|
|
||||||
commands:
|
|
||||||
# Close the Expo dev menu if it opens as a bottom sheet.
|
|
||||||
- tapOn:
|
|
||||||
point: "89%,9%"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Connect to the local daemon only when the app is on the welcome screen.
|
|
||||||
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible:
|
|
||||||
id: "welcome-screen"
|
|
||||||
commands:
|
|
||||||
- tapOn:
|
|
||||||
id: "welcome-direct-connection"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: "add-host-modal"
|
|
||||||
timeout: 10000
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
id: "direct-host-input"
|
|
||||||
- eraseText
|
|
||||||
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
|
||||||
|
|
||||||
- assertVisible:
|
|
||||||
id: "direct-host-input"
|
|
||||||
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
id: "direct-host-submit"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
|
|
||||||
# visible. Ends on the New Workspace screen.
|
|
||||||
|
|
||||||
- assertVisible:
|
|
||||||
id: "sidebar-project-list"
|
|
||||||
|
|
||||||
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
|
|
||||||
|
|
||||||
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "New workspace"
|
|
||||||
timeout: 30000
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Select a real provider/model on the New Workspace screen. Do not skip this
|
|
||||||
# in redirect tests: without a selected model, Create only shows validation and
|
|
||||||
# never exercises workspace creation.
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
id: "agent-preferences-button"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: "agent-preferences-sheet"
|
|
||||||
timeout: 10000
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
id: "combined-model-selector"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "Select model"
|
|
||||||
timeout: 30000
|
|
||||||
|
|
||||||
- tapOn: "Codex"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "GPT-5.4"
|
|
||||||
timeout: 30000
|
|
||||||
|
|
||||||
- tapOn: "GPT-5.4"
|
|
||||||
|
|
||||||
- tapOn: "Close"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "GPT-5.4"
|
|
||||||
timeout: 10000
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Start on the New Workspace screen after model selection. Taps Create and
|
|
||||||
# proves the redirect completed into a workspace draft-agent tab.
|
|
||||||
|
|
||||||
- tapOn: "Create"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: "workspace-header-title"
|
|
||||||
timeout: 60000
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: "message-input-root"
|
|
||||||
timeout: 30000
|
|
||||||
|
|
||||||
- assertNotVisible: "New workspace"
|
|
||||||
- assertNotVisible: "Select a model"
|
|
||||||
- assertNotVisible: "There was a problem loading the project."
|
|
||||||
- assertNotVisible: "The specified child already has a parent"
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# NOTE: Render through the workspace-create shell scripts before running when
|
|
||||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
|
||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Start from the connected home screen and leave the Android sidebar open with
|
|
||||||
# the prepared project visible.
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "What shall we build today?"
|
|
||||||
timeout: 60000
|
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
id: "menu-button"
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: "sidebar-project-list"
|
|
||||||
timeout: 10000
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: ${PASEO_MAESTRO_PROJECT_NAME}
|
|
||||||
timeout: 30000
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Records only the Android workspace-creation repro window.
|
|
||||||
#
|
|
||||||
# The setup Maestro flow gets the app to the open sidebar with a prepared
|
|
||||||
# project visible. Recording starts after that, then the focused flow taps the
|
|
||||||
# new-workspace button, selects a provider/model, taps Create, and asserts the
|
|
||||||
# app lands on the created workspace rather than remaining on /new.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
||||||
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
|
||||||
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
|
|
||||||
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
|
|
||||||
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
|
||||||
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
|
||||||
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
|
||||||
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
|
||||||
|
|
||||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
|
||||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
|
||||||
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
|
||||||
|
|
||||||
require_command() {
|
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
|
||||||
echo "Missing required command: $1" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
render_flow() {
|
|
||||||
local source="$1"
|
|
||||||
local target="$2"
|
|
||||||
mkdir -p "$(dirname "$target")"
|
|
||||||
perl -0pe '
|
|
||||||
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
|
||||||
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
|
||||||
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
|
||||||
' "$source" > "$target"
|
|
||||||
}
|
|
||||||
|
|
||||||
render_flow_tree() {
|
|
||||||
mkdir -p "$OUT_DIR/flows"
|
|
||||||
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
|
|
||||||
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
|
|
||||||
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
|
||||||
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
require_command adb
|
|
||||||
require_command git
|
|
||||||
require_command maestro
|
|
||||||
require_command node
|
|
||||||
require_command perl
|
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
|
||||||
|
|
||||||
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
|
||||||
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
|
||||||
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
|
||||||
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
|
||||||
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
|
||||||
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
|
||||||
|
|
||||||
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
|
|
||||||
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
|
|
||||||
render_flow_tree
|
|
||||||
|
|
||||||
echo "=== Focused Android Workspace Create Recording ==="
|
|
||||||
echo "Output dir: $OUT_DIR"
|
|
||||||
echo "Video: $LOCAL_VIDEO"
|
|
||||||
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
|
||||||
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
|
||||||
|
|
||||||
adb reverse tcp:6767 tcp:6767 >/dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Opening project in daemon..."
|
|
||||||
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import WebSocket from "ws";
|
|
||||||
|
|
||||||
const repoRoot = process.env.REPO_ROOT;
|
|
||||||
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
|
||||||
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
|
||||||
if (!repoRoot || !projectPath || !daemonUrl) {
|
|
||||||
throw new Error("Missing required environment for daemon project setup.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
|
||||||
const { DaemonClient } = await import(moduleUrl);
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: daemonUrl,
|
|
||||||
clientId: `maestro-workspace-create-focus-${Date.now()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
const payload = await client.openProject(projectPath);
|
|
||||||
if (payload.error || !payload.workspace) {
|
|
||||||
throw new Error(payload.error ?? "openProject returned no workspace");
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
workspaceId: payload.workspace.id,
|
|
||||||
projectDisplayName: payload.workspace.projectDisplayName,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
NODE
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Staging app at open sidebar..."
|
|
||||||
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Recording focused create flow..."
|
|
||||||
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
|
|
||||||
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
|
|
||||||
SCREENRECORD_PID=$!
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
set +e
|
|
||||||
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
|
|
||||||
FOCUS_STATUS=${PIPESTATUS[0]}
|
|
||||||
set -e
|
|
||||||
|
|
||||||
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
|
||||||
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
|
||||||
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
|
|
||||||
ls -lh "$LOCAL_VIDEO"
|
|
||||||
|
|
||||||
if [ "$FOCUS_STATUS" -ne 0 ]; then
|
|
||||||
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
|
|
||||||
exit "$FOCUS_STATUS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Focused recording complete."
|
|
||||||
echo "Artifacts: $OUT_DIR"
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Android Maestro harness for the workspace-creation redirect crash.
|
|
||||||
#
|
|
||||||
# Starts from a clean app state, connects the Android app to the local daemon,
|
|
||||||
# opens a prepared git project, creates a workspace through the UI, and captures
|
|
||||||
# adb logcat around the redirect window.
|
|
||||||
#
|
|
||||||
# This harness is deliberately stronger than "composer is visible": it selects
|
|
||||||
# a model, taps Create, asserts the workspace header, asserts the New Workspace
|
|
||||||
# route is gone, and fails if logcat contains the Android Fabric view-parent
|
|
||||||
# crash signature.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash packages/app/maestro/test-workspace-create-android-crash.sh
|
|
||||||
#
|
|
||||||
# Optional environment:
|
|
||||||
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
|
||||||
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
|
||||||
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
|
||||||
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
||||||
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
|
||||||
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
|
||||||
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
|
||||||
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
|
||||||
|
|
||||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
|
||||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
|
||||||
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
|
||||||
|
|
||||||
require_command() {
|
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
|
||||||
echo "Missing required command: $1" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
require_command adb
|
|
||||||
require_command git
|
|
||||||
require_command maestro
|
|
||||||
require_command node
|
|
||||||
require_command perl
|
|
||||||
|
|
||||||
render_flow() {
|
|
||||||
local source="$1"
|
|
||||||
local target="$2"
|
|
||||||
mkdir -p "$(dirname "$target")"
|
|
||||||
perl -0pe '
|
|
||||||
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
|
||||||
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
|
||||||
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
|
||||||
' "$source" > "$target"
|
|
||||||
}
|
|
||||||
|
|
||||||
render_flow_tree() {
|
|
||||||
mkdir -p "$OUT_DIR/flows"
|
|
||||||
render_flow "$FLOW_TEMPLATE" "$FLOW"
|
|
||||||
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
|
||||||
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -f "$SERVER_EXPORTS" ]; then
|
|
||||||
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
|
||||||
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
|
||||||
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
|
||||||
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
|
||||||
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
|
||||||
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
|
||||||
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
|
||||||
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
|
||||||
else
|
|
||||||
PROJECT_PARENT=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
|
||||||
|
|
||||||
echo "=== Workspace Create Android Crash Harness ==="
|
|
||||||
echo "Output dir: $OUT_DIR"
|
|
||||||
echo "App id: $PASEO_MAESTRO_APP_ID"
|
|
||||||
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
|
|
||||||
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
|
|
||||||
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
|
||||||
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
|
||||||
|
|
||||||
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
|
|
||||||
render_flow_tree
|
|
||||||
echo "Rendered flow: $FLOW"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Preparing Android port reverse..."
|
|
||||||
adb reverse tcp:6767 tcp:6767 >/dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Opening project in daemon..."
|
|
||||||
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import WebSocket from "ws";
|
|
||||||
|
|
||||||
const repoRoot = process.env.REPO_ROOT;
|
|
||||||
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
|
||||||
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
|
||||||
if (!repoRoot || !projectPath || !daemonUrl) {
|
|
||||||
throw new Error("Missing required environment for daemon project setup.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
|
||||||
const { DaemonClient } = await import(moduleUrl);
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: daemonUrl,
|
|
||||||
clientId: `maestro-workspace-create-${Date.now()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
const payload = await client.openProject(projectPath);
|
|
||||||
if (payload.error || !payload.workspace) {
|
|
||||||
throw new Error(payload.error ?? "openProject returned no workspace");
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
workspaceId: payload.workspace.id,
|
|
||||||
projectId: payload.workspace.projectId,
|
|
||||||
projectDisplayName: payload.workspace.projectDisplayName,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
NODE
|
|
||||||
|
|
||||||
LOGCAT_PID=""
|
|
||||||
cleanup() {
|
|
||||||
if [ -n "$LOGCAT_PID" ]; then
|
|
||||||
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Capturing Android logcat..."
|
|
||||||
adb logcat -c || true
|
|
||||||
adb logcat -v time > "$OUT_DIR/logcat.txt" &
|
|
||||||
LOGCAT_PID="$!"
|
|
||||||
|
|
||||||
echo "Running Maestro flow..."
|
|
||||||
set +e
|
|
||||||
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
|
|
||||||
MAESTRO_STATUS=${PIPESTATUS[0]}
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cleanup
|
|
||||||
LOGCAT_PID=""
|
|
||||||
|
|
||||||
if [ "$MAESTRO_STATUS" -ne 0 ]; then
|
|
||||||
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
|
||||||
echo ""
|
|
||||||
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
|
|
||||||
exit "$MAESTRO_STATUS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
|
|
||||||
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
|
||||||
echo ""
|
|
||||||
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
|
|
||||||
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
|
|
||||||
echo "Artifacts: $OUT_DIR"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Android regression/reproduction flow for creating a workspace from an existing project.
|
|
||||||
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
|
|
||||||
|
|
||||||
- launchApp:
|
|
||||||
clearState: true
|
|
||||||
|
|
||||||
- runFlow: flows/android-dev-client.yaml
|
|
||||||
- runFlow: flows/connect-direct-if-welcome.yaml
|
|
||||||
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "What shall we build today?"
|
|
||||||
timeout: 60000
|
|
||||||
|
|
||||||
- takeScreenshot: 01-connected
|
|
||||||
|
|
||||||
- runFlow: flows/open-prepared-project-sidebar.yaml
|
|
||||||
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
|
||||||
|
|
||||||
- takeScreenshot: 02-new-workspace
|
|
||||||
|
|
||||||
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
|
||||||
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
|
||||||
|
|
||||||
- takeScreenshot: 03-created-workspace
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
|
|
||||||
# already left the sidebar open with the prepared project visible.
|
|
||||||
|
|
||||||
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
|
||||||
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
|
||||||
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
appId: ${PASEO_MAESTRO_APP_ID}
|
|
||||||
---
|
|
||||||
# Setup-only flow. It stops with the Android sidebar open and the prepared
|
|
||||||
# project visible, so focused recordings can start at the actual repro window.
|
|
||||||
|
|
||||||
- launchApp:
|
|
||||||
clearState: true
|
|
||||||
|
|
||||||
- runFlow: flows/android-dev-client.yaml
|
|
||||||
- runFlow: flows/connect-direct-if-welcome.yaml
|
|
||||||
- runFlow: flows/open-prepared-project-sidebar.yaml
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.62",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "APP_VARIANT=development expo start",
|
"start": "APP_VARIANT=development expo start",
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --project browser",
|
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
@@ -32,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.62",
|
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.62",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.62",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -113,7 +112,6 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/browser": "^3.2.4",
|
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
|||||||
+118
-128
@@ -1,8 +1,5 @@
|
|||||||
import "@/styles/unistyles";
|
import "@/styles/unistyles";
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
useGlobalSearchParams,
|
useGlobalSearchParams,
|
||||||
@@ -10,92 +7,90 @@ import {
|
|||||||
usePathname,
|
usePathname,
|
||||||
useRouter,
|
useRouter,
|
||||||
} from "expo-router";
|
} from "expo-router";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
useHosts,
|
||||||
|
useHostMutations,
|
||||||
|
useHostRuntimeClient,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, Text, View } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
import * as Linking from "expo-linking";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import * as Notifications from "expo-notifications";
|
||||||
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 { CommandCenter } from "@/components/command-center";
|
|
||||||
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
|
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||||
import {
|
|
||||||
HorizontalScrollProvider,
|
|
||||||
useHorizontalScrollOptional,
|
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
|
||||||
import {
|
import {
|
||||||
SidebarAnimationProvider,
|
SidebarAnimationProvider,
|
||||||
useSidebarAnimation,
|
useSidebarAnimation,
|
||||||
} from "@/contexts/sidebar-animation-context";
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
|
||||||
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 { 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 { useOpenProject } from "@/hooks/use-open-project";
|
|
||||||
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
|
||||||
import { queryClient } from "@/query/query-client";
|
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
HorizontalScrollProvider,
|
||||||
useHostMutations,
|
useHorizontalScrollOptional,
|
||||||
useHostRuntimeClient,
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
useHosts,
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
} from "@/runtime/host-runtime";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
|
import {
|
||||||
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
|
type WebNotificationClickDetail,
|
||||||
|
ensureOsNotificationPermission,
|
||||||
|
} from "@/utils/os-notifications";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
|
import {
|
||||||
|
buildHostRootRoute,
|
||||||
|
mapPathnameToServer,
|
||||||
|
parseServerIdFromPathname,
|
||||||
|
parseHostAgentRouteFromPathname,
|
||||||
|
parseWorkspaceOpenIntent,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import {
|
import {
|
||||||
addBrowserActiveWorkspaceLocationListener,
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
syncNavigationActiveWorkspace,
|
syncNavigationActiveWorkspace,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
|
||||||
import { resolveActiveHost } from "@/utils/active-host";
|
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
|
||||||
import {
|
|
||||||
buildHostRootRoute,
|
|
||||||
decodeWorkspaceIdFromPathSegment,
|
|
||||||
mapPathnameToServer,
|
|
||||||
parseHostAgentRouteFromPathname,
|
|
||||||
parseServerIdFromPathname,
|
|
||||||
parseWorkspaceOpenIntent,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
|
||||||
import {
|
|
||||||
ensureOsNotificationPermission,
|
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
|
||||||
type WebNotificationClickDetail,
|
|
||||||
} from "@/utils/os-notifications";
|
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -129,36 +124,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
|
||||||
const target = resolveNotificationTarget(data);
|
|
||||||
const serverId = target.serverId;
|
|
||||||
const agentId = target.agentId;
|
|
||||||
if (serverId && agentId) {
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
|
||||||
const agent = session?.agents.get(agentId);
|
|
||||||
const workspaceId =
|
|
||||||
target.workspaceId ??
|
|
||||||
resolveWorkspaceIdByExecutionDirectory({
|
|
||||||
workspaces: session?.workspaces.values(),
|
|
||||||
workspaceDirectory: agent?.cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (workspaceId) {
|
|
||||||
prepareWorkspaceTab({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
pin: true,
|
|
||||||
});
|
|
||||||
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.navigate(buildNotificationRoute(data));
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -179,7 +145,7 @@ function PushNotificationRouter() {
|
|||||||
(payload as { data?: unknown }).data !== null
|
(payload as { data?: unknown }).data !== null
|
||||||
? (payload as { data: Record<string, unknown> }).data
|
? (payload as { data: Record<string, unknown> }).data
|
||||||
: undefined;
|
: undefined;
|
||||||
openNotification(data);
|
router.push(buildNotificationRoute(data) as any);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,7 +165,7 @@ function PushNotificationRouter() {
|
|||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openNotification(customEvent.detail?.data);
|
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
@@ -232,7 +198,7 @@ function PushNotificationRouter() {
|
|||||||
const data = response.notification.request.content.data as
|
const data = response.notification.request.content.data as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
openNotification(data);
|
router.push(buildNotificationRoute(data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||||
@@ -246,7 +212,7 @@ function PushNotificationRouter() {
|
|||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [openNotification]);
|
}, [router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -353,9 +319,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setPhase("connecting");
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
setError(null);
|
|
||||||
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPhase("online");
|
setPhase("online");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -437,6 +401,8 @@ function AppContainer({
|
|||||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
@@ -473,6 +439,35 @@ function AppContainer({
|
|||||||
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||||
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
|
const isElectron = getIsElectronRuntime();
|
||||||
|
const windowW = isWeb ? window.innerWidth : undefined;
|
||||||
|
const windowH = isWeb ? window.innerHeight : undefined;
|
||||||
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||||
|
const ua = isWeb ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[layout-debug]",
|
||||||
|
JSON.stringify({
|
||||||
|
breakpoint: bp,
|
||||||
|
isCompactLayout,
|
||||||
|
isElectron,
|
||||||
|
chromeEnabled,
|
||||||
|
isFocusModeEnabled,
|
||||||
|
agentListOpen,
|
||||||
|
sidebarWidth,
|
||||||
|
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||||
|
unistylesScreen: { w: screenW, h: screenH },
|
||||||
|
window: { w: windowW, h: windowH },
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
userAgent: ua,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: keyboardShortcutsEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
@@ -499,8 +494,7 @@ function AppContainer({
|
|||||||
</View>
|
</View>
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<UpdateCalloutSource />
|
<UpdateBanner />
|
||||||
<DaemonVersionMismatchCalloutSource />
|
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
<WorkspaceShortcutTargetsSubscriber
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
@@ -789,10 +783,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const shouldShowAppChrome =
|
const shouldShowAppChrome = activeServerId !== null;
|
||||||
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeServerId || hosts.length === 0) {
|
if (!activeServerId || hosts.length === 0) {
|
||||||
@@ -848,7 +840,6 @@ function RootStack() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings/index" />
|
<Stack.Screen name="settings/index" />
|
||||||
@@ -868,6 +859,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -907,20 +899,18 @@ export default function RootLayout() {
|
|||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<PushNotificationRouter />
|
<PushNotificationRouter />
|
||||||
<SidebarCalloutProvider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<ProvidersWrapper>
|
||||||
<ProvidersWrapper>
|
<SidebarAnimationProvider>
|
||||||
<SidebarAnimationProvider>
|
<HorizontalScrollProvider>
|
||||||
<HorizontalScrollProvider>
|
<OpenProjectListener />
|
||||||
<OpenProjectListener />
|
<AppWithSidebar>
|
||||||
<AppWithSidebar>
|
<RootStack />
|
||||||
<RootStack />
|
</AppWithSidebar>
|
||||||
</AppWithSidebar>
|
</HorizontalScrollProvider>
|
||||||
</HorizontalScrollProvider>
|
</SidebarAnimationProvider>
|
||||||
</SidebarAnimationProvider>
|
</ProvidersWrapper>
|
||||||
</ProvidersWrapper>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
|
||||||
</SidebarCalloutProvider>
|
|
||||||
</HostRuntimeBootstrapProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -70,11 +69,9 @@ function clearConsumedOpenIntent(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
|
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||||
input.navigation.setParams({ open: undefined });
|
input.navigation.setParams({ open: undefined });
|
||||||
if (isWeb) {
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
|
||||||
stripOpenSearchParamFromBrowserUrl();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
@@ -89,7 +86,6 @@ function HostWorkspaceLayoutContent() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
@@ -130,9 +126,6 @@ function HostWorkspaceLayoutContent() {
|
|||||||
if (!rootNavigationState?.key) {
|
if (!rootNavigationState?.key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasHydratedWorkspaceLayoutStore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
@@ -168,17 +161,9 @@ function HostWorkspaceLayoutContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setIntentConsumed(true);
|
setIntentConsumed(true);
|
||||||
}, [
|
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
|
||||||
hasHydratedWorkspaceLayoutStore,
|
|
||||||
navigation,
|
|
||||||
openValue,
|
|
||||||
rootNavigationState?.key,
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
if (openValue && !intentConsumed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
|
||||||
|
|
||||||
const fileSystemMock = vi.hoisted(() => ({
|
|
||||||
getInfoAsync: vi.fn(async (uri: string) => {
|
|
||||||
if (uri.endsWith("/preview-assets/")) {
|
|
||||||
return { exists: true, isDirectory: true };
|
|
||||||
}
|
|
||||||
return { exists: true, isDirectory: false, size: 4 };
|
|
||||||
}),
|
|
||||||
makeDirectoryAsync: vi.fn(async () => {}),
|
|
||||||
writeAsStringAsync: vi.fn(async () => {}),
|
|
||||||
copyAsync: vi.fn(async () => {}),
|
|
||||||
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
|
||||||
deleteAsync: vi.fn(async () => {}),
|
|
||||||
readDirectoryAsync: vi.fn(async () => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-file-system/legacy", () => ({
|
|
||||||
cacheDirectory: "file:///cache/",
|
|
||||||
EncodingType: { Base64: "base64" },
|
|
||||||
getInfoAsync: fileSystemMock.getInfoAsync,
|
|
||||||
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
|
||||||
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
|
||||||
copyAsync: fileSystemMock.copyAsync,
|
|
||||||
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
|
||||||
deleteAsync: fileSystemMock.deleteAsync,
|
|
||||||
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("local file attachment store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
fileSystemMock.getInfoAsync.mockClear();
|
|
||||||
fileSystemMock.makeDirectoryAsync.mockClear();
|
|
||||||
fileSystemMock.writeAsStringAsync.mockClear();
|
|
||||||
fileSystemMock.copyAsync.mockClear();
|
|
||||||
fileSystemMock.readAsStringAsync.mockClear();
|
|
||||||
fileSystemMock.deleteAsync.mockClear();
|
|
||||||
fileSystemMock.readDirectoryAsync.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes raw base64 sources directly to the managed file path", async () => {
|
|
||||||
const store = createLocalFileAttachmentStore({
|
|
||||||
storageType: "native-file",
|
|
||||||
baseDirectoryName: "preview-assets",
|
|
||||||
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachment = await store.save({
|
|
||||||
id: "preview_8_test",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "result.png",
|
|
||||||
source: { kind: "base64", base64: "AAECAw==" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
|
||||||
"file:///cache/preview-assets/preview_8_test.png",
|
|
||||||
"AAECAw==",
|
|
||||||
{ encoding: "base64" },
|
|
||||||
);
|
|
||||||
expect(attachment).toMatchObject({
|
|
||||||
id: "preview_8_test",
|
|
||||||
mimeType: "image/png",
|
|
||||||
storageType: "native-file",
|
|
||||||
storageKey: "/cache/preview-assets/preview_8_test.png",
|
|
||||||
fileName: "result.png",
|
|
||||||
byteSize: 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -72,13 +72,6 @@ async function writeFromSource(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const base64 = await blobToBase64(input.source.blob);
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
|
||||||
import { __setAttachmentStoreForTests } from "./store";
|
|
||||||
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
|
||||||
|
|
||||||
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
|
||||||
return {
|
|
||||||
id: input.id ?? "att_1",
|
|
||||||
mimeType: input.mimeType ?? "image/png",
|
|
||||||
storageType: input.storageType ?? "web-indexeddb",
|
|
||||||
storageKey: input.storageKey ?? "att_1",
|
|
||||||
fileName: input.fileName,
|
|
||||||
byteSize: input.byteSize,
|
|
||||||
createdAt: input.createdAt ?? 1700000000000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRecordingStore(): AttachmentStore & {
|
|
||||||
savedSources: SaveAttachmentInput[];
|
|
||||||
releasedUrls: string[];
|
|
||||||
} {
|
|
||||||
const savedSources: SaveAttachmentInput[] = [];
|
|
||||||
const releasedUrls: string[] = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
storageType: "web-indexeddb",
|
|
||||||
savedSources,
|
|
||||||
releasedUrls,
|
|
||||||
async save(input) {
|
|
||||||
savedSources.push(input);
|
|
||||||
return createAttachment({
|
|
||||||
id: input.id,
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
fileName: input.fileName,
|
|
||||||
byteSize: 4,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async encodeBase64({ attachment }) {
|
|
||||||
return `${attachment.id}:base64`;
|
|
||||||
},
|
|
||||||
async resolvePreviewUrl({ attachment }) {
|
|
||||||
return `blob:${attachment.id}`;
|
|
||||||
},
|
|
||||||
async releasePreviewUrl({ url }) {
|
|
||||||
releasedUrls.push(url);
|
|
||||||
},
|
|
||||||
async delete() {},
|
|
||||||
async garbageCollect() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("attachment service", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
__setAttachmentStoreForTests(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
|
||||||
const store = createRecordingStore();
|
|
||||||
__setAttachmentStoreForTests(store);
|
|
||||||
|
|
||||||
const attachment = await persistAttachmentFromBase64({
|
|
||||||
id: "att_base64",
|
|
||||||
base64: "AAECAw==",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "image.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(attachment).toEqual({
|
|
||||||
id: "att_base64",
|
|
||||||
mimeType: "image/png",
|
|
||||||
storageType: "web-indexeddb",
|
|
||||||
storageKey: "att_1",
|
|
||||||
fileName: "image.png",
|
|
||||||
byteSize: 4,
|
|
||||||
createdAt: 1700000000000,
|
|
||||||
});
|
|
||||||
expect(store.savedSources).toEqual([
|
|
||||||
{
|
|
||||||
id: "att_base64",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "image.png",
|
|
||||||
source: { kind: "base64", base64: "AAECAw==" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps provider send output byte-compatible", async () => {
|
|
||||||
const store = createRecordingStore();
|
|
||||||
__setAttachmentStoreForTests(store);
|
|
||||||
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
|
|
||||||
|
|
||||||
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
|
|
||||||
{ data: "att_send:base64", mimeType: "image/jpeg" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -31,21 +31,6 @@ export async function persistAttachmentFromDataUrl(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistAttachmentFromBase64(input: {
|
|
||||||
base64: string;
|
|
||||||
mimeType?: string;
|
|
||||||
fileName?: string | null;
|
|
||||||
id?: string;
|
|
||||||
}): Promise<AttachmentMetadata> {
|
|
||||||
const store = await getAttachmentStore();
|
|
||||||
return await store.save({
|
|
||||||
id: input.id,
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
fileName: input.fileName,
|
|
||||||
source: { kind: "base64", base64: input.base64 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistAttachmentFromFileUri(input: {
|
export async function persistAttachmentFromFileUri(input: {
|
||||||
uri: string;
|
uri: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export type ComposerAttachment =
|
|||||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||||
|
|
||||||
export type AttachmentDataSource =
|
export type AttachmentDataSource =
|
||||||
| { kind: "base64"; base64: string }
|
|
||||||
| { kind: "blob"; blob: Blob }
|
| { kind: "blob"; blob: Blob }
|
||||||
| { kind: "data_url"; dataUrl: string }
|
| { kind: "data_url"; dataUrl: string }
|
||||||
| { kind: "file_uri"; uri: string };
|
| { kind: "file_uri"; uri: string };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
|
import { pathToFileUri } from "./utils";
|
||||||
|
|
||||||
describe("pathToFileUri", () => {
|
describe("pathToFileUri", () => {
|
||||||
it("converts POSIX absolute paths to file URIs", () => {
|
it("converts POSIX absolute paths to file URIs", () => {
|
||||||
@@ -22,35 +22,3 @@ describe("pathToFileUri", () => {
|
|||||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseDataUrl", () => {
|
|
||||||
it("accepts base64 data URLs with media-type parameters", () => {
|
|
||||||
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
|
|
||||||
mimeType: "image/png",
|
|
||||||
base64: "AAECAw==",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-base64 data URLs", () => {
|
|
||||||
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
|
|
||||||
"Attachment data URL is not base64 encoded.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseImageDataUrl", () => {
|
|
||||||
it("returns a compact cache key for image data URLs", () => {
|
|
||||||
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
|
|
||||||
|
|
||||||
expect(parseImageDataUrl(dataUrl)).toMatchObject({
|
|
||||||
mimeType: "image/png",
|
|
||||||
base64: "a".repeat(512),
|
|
||||||
});
|
|
||||||
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
|
|
||||||
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-image data URLs", () => {
|
|
||||||
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -14,17 +14,11 @@ export function normalizeMimeType(input: string | undefined | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
||||||
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
|
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("Malformed data URL for attachment.");
|
throw new Error("Malformed data URL for attachment.");
|
||||||
}
|
}
|
||||||
const metadata = match[1] ?? "";
|
const [, mimeTypeRaw, base64] = match;
|
||||||
const base64 = match[2]?.replace(/\s/g, "");
|
|
||||||
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
|
|
||||||
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
|
|
||||||
if (!isBase64) {
|
|
||||||
throw new Error("Attachment data URL is not base64 encoded.");
|
|
||||||
}
|
|
||||||
if (!base64) {
|
if (!base64) {
|
||||||
throw new Error("Attachment data URL is missing base64 payload.");
|
throw new Error("Attachment data URL is missing base64 payload.");
|
||||||
}
|
}
|
||||||
@@ -34,67 +28,6 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashString(value: string): string {
|
|
||||||
let hash = 2166136261;
|
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
|
||||||
hash ^= value.charCodeAt(index);
|
|
||||||
hash = Math.imul(hash, 16777619);
|
|
||||||
}
|
|
||||||
return (hash >>> 0).toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseImageDataUrl(
|
|
||||||
uri: string,
|
|
||||||
): { mimeType: string; base64: string; cacheKey: string } | null {
|
|
||||||
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = parseDataUrl(uri);
|
|
||||||
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
|
|
||||||
return {
|
|
||||||
...parsed,
|
|
||||||
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createImageSourceCacheKey(source: string): string {
|
|
||||||
return parseImageDataUrl(source)?.cacheKey ?? source;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileNameFromPath(path: string | null | undefined): string | null {
|
|
||||||
const trimmed = path?.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
||||||
const fileName = normalized.split("/").pop()?.trim();
|
|
||||||
return fileName || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPreviewAttachmentId(input: {
|
|
||||||
mimeType: string;
|
|
||||||
path?: string | null;
|
|
||||||
size?: number | null;
|
|
||||||
modifiedAt?: string | null;
|
|
||||||
contentLength?: number | null;
|
|
||||||
}): string {
|
|
||||||
const path = input.path?.trim() ?? "";
|
|
||||||
const size = Number.isFinite(input.size) ? String(input.size) : "";
|
|
||||||
const modifiedAt = input.modifiedAt?.trim() ?? "";
|
|
||||||
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
|
|
||||||
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
|
|
||||||
return `preview_${size || contentLength || "unknown"}_${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|||||||
@@ -74,15 +74,6 @@ 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 }> {
|
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||||
const source = input.source;
|
const source = input.source;
|
||||||
if (source.kind === "blob") {
|
if (source.kind === "blob") {
|
||||||
@@ -105,14 +96,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 response = await fetch(source.uri);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { Agent } from "@/stores/session-store";
|
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
@@ -39,62 +37,6 @@ type FlatListItem =
|
|||||||
| { type: "header"; key: string; title: string }
|
| { type: "header"; key: string; title: string }
|
||||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||||
|
|
||||||
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
|
||||||
return {
|
|
||||||
serverId: agent.serverId,
|
|
||||||
id: agent.id,
|
|
||||||
provider: agent.provider,
|
|
||||||
status: agent.status,
|
|
||||||
createdAt: agent.createdAt,
|
|
||||||
updatedAt: agent.lastActivityAt,
|
|
||||||
lastUserMessageAt: null,
|
|
||||||
lastActivityAt: agent.lastActivityAt,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: false,
|
|
||||||
supportsSessionPersistence: false,
|
|
||||||
supportsDynamicModes: false,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: false,
|
|
||||||
supportsToolInvocations: false,
|
|
||||||
},
|
|
||||||
currentModeId: null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: null,
|
|
||||||
runtimeInfo: {
|
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: null,
|
|
||||||
},
|
|
||||||
title: agent.title,
|
|
||||||
cwd: agent.cwd,
|
|
||||||
model: null,
|
|
||||||
thinkingOptionId: null,
|
|
||||||
requiresAttention: agent.requiresAttention,
|
|
||||||
attentionReason: agent.attentionReason,
|
|
||||||
attentionTimestamp: agent.attentionTimestamp,
|
|
||||||
archivedAt: agent.archivedAt,
|
|
||||||
labels: agent.labels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
|
||||||
if (!agent.archivedAt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
|
|
||||||
const existing = previous.get(agent.id);
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set(agent.id, {
|
|
||||||
...buildHistoricalAgentDetail(agent),
|
|
||||||
...existing,
|
|
||||||
archivedAt: existing?.archivedAt ?? agent.archivedAt,
|
|
||||||
cwd: existing?.cwd ?? agent.cwd,
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
@@ -281,7 +223,6 @@ export function AgentList({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const { archiveAgent } = useArchiveAgent();
|
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
@@ -310,7 +251,6 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rememberArchivedAgentDetail(agent);
|
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -322,23 +262,20 @@ export function AgentList({
|
|||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentLongPress = useCallback(
|
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||||
(agent: AggregatedAgent) => {
|
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
if (isRunning) {
|
||||||
if (isRunning) {
|
setActionAgent(agent);
|
||||||
setActionAgent(agent);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setActionAgent(agent);
|
setActionAgent(agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {});
|
void client.archiveAgent(agent.id);
|
||||||
},
|
}, []);
|
||||||
[archiveAgent],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCloseActionSheet = useCallback(() => {
|
const handleCloseActionSheet = useCallback(() => {
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
@@ -349,9 +286,9 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Timeout errors are swallowed — the daemon will still process the archive
|
// Timeout errors are swallowed — the daemon will still process the archive
|
||||||
void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {});
|
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient, archiveAgent]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
const flatItems = useMemo((): FlatListItem[] => {
|
const flatItems = useMemo((): FlatListItem[] => {
|
||||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||||
|
|||||||
@@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||||
|
|
||||||
const snapshotSelectedEntry = useMemo(() => {
|
const snapshotSelectedEntry = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
|
|||||||
@@ -79,43 +79,6 @@ import { isWeb } from "@/constants/platform";
|
|||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
|
|
||||||
const isSameAssistantBlockGroup = (params: {
|
|
||||||
item: StreamItem | null | undefined;
|
|
||||||
other: StreamItem | null | undefined;
|
|
||||||
}) =>
|
|
||||||
params.item?.kind === "assistant_message" &&
|
|
||||||
params.other?.kind === "assistant_message" &&
|
|
||||||
params.item.blockGroupId !== undefined &&
|
|
||||||
params.item.blockGroupId === params.other.blockGroupId;
|
|
||||||
|
|
||||||
const getAssistantBlockSpacing = (params: {
|
|
||||||
item: StreamItem;
|
|
||||||
aboveItem: StreamItem | null | undefined;
|
|
||||||
belowItem: StreamItem | null | undefined;
|
|
||||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" => {
|
|
||||||
if (params.item.kind !== "assistant_message") {
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
const compactTop = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.aboveItem,
|
|
||||||
});
|
|
||||||
const compactBottom = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.belowItem,
|
|
||||||
});
|
|
||||||
if (compactTop && compactBottom) {
|
|
||||||
return "compactBoth";
|
|
||||||
}
|
|
||||||
if (compactTop) {
|
|
||||||
return "compactTop";
|
|
||||||
}
|
|
||||||
if (compactBottom) {
|
|
||||||
return "compactBottom";
|
|
||||||
}
|
|
||||||
return "default";
|
|
||||||
};
|
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
prepareForViewportChange(): void;
|
prepareForViewportChange(): void;
|
||||||
@@ -286,7 +249,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tightGap = theme.spacing[1]; // 4px
|
const tightGap = theme.spacing[1]; // 4px
|
||||||
const assistantBlockGap = theme.spacing[3]; // 12px
|
|
||||||
const looseGap = theme.spacing[4]; // 16px
|
const looseGap = theme.spacing[4]; // 16px
|
||||||
|
|
||||||
const getGapBetween = useCallback(
|
const getGapBetween = useCallback(
|
||||||
@@ -299,23 +261,26 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||||
return 0;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||||
return looseGap;
|
return looseGap;
|
||||||
}
|
}
|
||||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
if (
|
||||||
|
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||||
|
isToolSequenceItem(belowItem)
|
||||||
|
) {
|
||||||
|
return tightGap;
|
||||||
|
}
|
||||||
|
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||||
return looseGap;
|
return tightGap;
|
||||||
}
|
|
||||||
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
|
||||||
return assistantBlockGap;
|
|
||||||
}
|
}
|
||||||
return looseGap;
|
return looseGap;
|
||||||
},
|
},
|
||||||
[assistantBlockGap, looseGap, tightGap],
|
[looseGap, tightGap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
@@ -370,27 +335,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "assistant_message": {
|
case "assistant_message":
|
||||||
const aboveItem =
|
|
||||||
getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "above",
|
|
||||||
}) ??
|
|
||||||
seamAboveItem ??
|
|
||||||
undefined;
|
|
||||||
const belowItem = getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
});
|
|
||||||
const spacing = getAssistantBlockSpacing({
|
|
||||||
item,
|
|
||||||
aboveItem,
|
|
||||||
belowItem,
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
@@ -399,10 +344,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
client={client}
|
client={client}
|
||||||
spacing={spacing}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
case "thought": {
|
case "thought": {
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -542,27 +485,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showWorkingIndicator = agent.status === "running";
|
const showWorkingIndicator = agent.status === "running";
|
||||||
const pendingPermissionsNode = useMemo(
|
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||||
() =>
|
const pendingPermissionsNode =
|
||||||
pendingPermissionItems.length > 0 ? (
|
pendingPermissionItems.length > 0 ? (
|
||||||
<View style={stylesheet.permissionsContainer}>
|
<View style={stylesheet.permissionsContainer}>
|
||||||
{pendingPermissionItems.map((permission) => (
|
{pendingPermissionItems.map((permission) => (
|
||||||
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null,
|
) : null;
|
||||||
[client, pendingPermissionItems],
|
const workingIndicatorNode = showWorkingIndicator ? (
|
||||||
);
|
<View style={stylesheet.bottomBarWrapper}>
|
||||||
const workingIndicatorNode = useMemo(
|
<WorkingIndicator />
|
||||||
() =>
|
</View>
|
||||||
showWorkingIndicator ? (
|
) : null;
|
||||||
<View style={stylesheet.bottomBarWrapper}>
|
|
||||||
<WorkingIndicator />
|
|
||||||
</View>
|
|
||||||
) : null,
|
|
||||||
[showWorkingIndicator],
|
|
||||||
);
|
|
||||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
|
||||||
return {
|
return {
|
||||||
...baseRenderModel,
|
...baseRenderModel,
|
||||||
boundary: {
|
boundary: {
|
||||||
@@ -577,7 +514,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workingIndicator: workingIndicatorNode,
|
workingIndicator: workingIndicatorNode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
|
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
|
||||||
|
|
||||||
const listEmptyComponent = useMemo(() => {
|
const listEmptyComponent = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -642,11 +579,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
stylesheet.listHeaderContent,
|
stylesheet.listHeaderContent,
|
||||||
boundary.hasLiveHead
|
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
|
||||||
? streamRenderStrategy.getFlatListInverted()
|
|
||||||
? { paddingBottom: looseGap }
|
|
||||||
: { paddingTop: looseGap }
|
|
||||||
: null,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{auxiliary.pendingPermissions}
|
{auxiliary.pendingPermissions}
|
||||||
@@ -654,13 +587,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [
|
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]);
|
||||||
auxiliary.pendingPermissions,
|
|
||||||
auxiliary.workingIndicator,
|
|
||||||
boundary.hasLiveHead,
|
|
||||||
looseGap,
|
|
||||||
streamRenderStrategy,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const renderers = useMemo<StreamSegmentRenderers>(
|
const renderers = useMemo<StreamSegmentRenderers>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1069,7 +996,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
marginTop: theme.spacing[4],
|
|
||||||
paddingLeft: 3,
|
paddingLeft: 3,
|
||||||
paddingRight: 3,
|
paddingRight: 3,
|
||||||
paddingTop: theme.spacing[3],
|
paddingTop: theme.spacing[3],
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
iconSize: { sm: 14, md: 18 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { sm: 4, md: 6, lg: 8, xl: 12, "2xl": 16, full: 999 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
|
||||||
opacity: { 50: 0.5 },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#111",
|
|
||||||
surface0: "#000",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
surface3: "#333",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
borderAccent: "#666",
|
|
||||||
accent: "#0a84ff",
|
|
||||||
accentForeground: "#fff",
|
|
||||||
destructive: "#ff4444",
|
|
||||||
primary: "#0a84ff",
|
|
||||||
palette: { white: "#fff" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
X: createIcon("X"),
|
|
||||||
CheckCircle2: createIcon("CheckCircle2"),
|
|
||||||
AlertTriangle: createIcon("AlertTriangle"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import { CalloutCard } from "./callout-card";
|
|
||||||
|
|
||||||
describe("CalloutCard", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders title and description", () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
expect(container?.textContent).toContain("v1.2.3 is ready to install.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an icon next to the title", () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<CalloutCard title="Update available" icon={<span data-testid="callout-title-icon" />} />,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders one action when one is provided", () => {
|
|
||||||
const onPress = vi.fn();
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<CalloutCard
|
|
||||||
description="Saved."
|
|
||||||
actions={[{ label: "Undo", onPress }]}
|
|
||||||
testID="callout"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = container?.querySelector(
|
|
||||||
'[data-testid="callout-action-0"]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(button).not.toBeNull();
|
|
||||||
expect(button?.textContent).toContain("Undo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders up to two actions", () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<CalloutCard
|
|
||||||
title="Update available"
|
|
||||||
description="v1 ready."
|
|
||||||
actions={[
|
|
||||||
{ label: "What's new", onPress: vi.fn() },
|
|
||||||
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
|
|
||||||
]}
|
|
||||||
testID="callout"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-action-0"]')?.textContent).toContain(
|
|
||||||
"What's new",
|
|
||||||
);
|
|
||||||
expect(container?.querySelector('[data-testid="callout-action-1"]')?.textContent).toContain(
|
|
||||||
"Install & restart",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders no action row when no actions are provided", () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<CalloutCard description="Copied" testID="callout" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
|
|
||||||
const onDismiss = vi.fn();
|
|
||||||
act(() => {
|
|
||||||
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dismissButton = container?.querySelector(
|
|
||||||
'[data-testid="callout-dismiss"]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(dismissButton).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the dismiss button when onDismiss is not provided", () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<CalloutCard description="Saved" testID="callout" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { X } from "lucide-react-native";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Pressable, Text, View } from "react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
|
|
||||||
export type CalloutActionVariant = "primary" | "secondary";
|
|
||||||
|
|
||||||
export interface CalloutAction {
|
|
||||||
label: string;
|
|
||||||
onPress: () => void;
|
|
||||||
variant?: CalloutActionVariant;
|
|
||||||
disabled?: boolean;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CalloutVariant = "default" | "success" | "error";
|
|
||||||
|
|
||||||
export interface CalloutCardProps {
|
|
||||||
title?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
variant?: CalloutVariant;
|
|
||||||
actions?: readonly CalloutAction[];
|
|
||||||
onDismiss?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
|
|
||||||
return <Text style={styles.description}>{children}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalloutCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
variant = "default",
|
|
||||||
actions,
|
|
||||||
onDismiss,
|
|
||||||
testID,
|
|
||||||
}: CalloutCardProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const visibleActions = (actions ?? []).slice(0, 2);
|
|
||||||
const hasHeader = title != null || icon != null;
|
|
||||||
const hasDescription = description != null && description !== "";
|
|
||||||
|
|
||||||
const containerStyle = [styles.container, variant === "error" ? styles.containerError : null];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
|
||||||
<View style={styles.body}>
|
|
||||||
{hasHeader || onDismiss ? (
|
|
||||||
<View style={styles.topRow}>
|
|
||||||
{hasHeader ? (
|
|
||||||
<View style={styles.header}>
|
|
||||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
|
||||||
{title ? (
|
|
||||||
<Text style={styles.title} numberOfLines={2}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{onDismiss ? (
|
|
||||||
<Pressable
|
|
||||||
onPress={onDismiss}
|
|
||||||
hitSlop={8}
|
|
||||||
style={styles.dismissButton}
|
|
||||||
testID={testID ? `${testID}-dismiss` : undefined}
|
|
||||||
accessibilityLabel="Dismiss"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<X
|
|
||||||
size={14}
|
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasDescription ? (
|
|
||||||
typeof description === "string" ? (
|
|
||||||
<CalloutDescriptionText>{description}</CalloutDescriptionText>
|
|
||||||
) : (
|
|
||||||
<View style={styles.descriptionSlot}>{description}</View>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{visibleActions.length > 0 ? (
|
|
||||||
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
|
||||||
{visibleActions.map((action, index) => (
|
|
||||||
<CalloutActionButton
|
|
||||||
key={`${action.label}-${index}`}
|
|
||||||
action={action}
|
|
||||||
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
|
|
||||||
const isPrimary = action.variant === "primary";
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={action.onPress}
|
|
||||||
disabled={action.disabled}
|
|
||||||
testID={testID}
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.actionButton,
|
|
||||||
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
|
|
||||||
pressed ? styles.actionButtonPressed : null,
|
|
||||||
action.disabled ? styles.actionButtonDisabled : null,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.actionLabel,
|
|
||||||
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
borderTopWidth: theme.borderWidth[1],
|
|
||||||
borderTopColor: theme.colors.border,
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
containerError: {
|
|
||||||
borderTopColor: theme.colors.destructive,
|
|
||||||
},
|
|
||||||
dismissButton: {
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
topRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
iconSlot: {
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
descriptionSlot: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
actionRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[2],
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
actionButtonPrimary: {
|
|
||||||
backgroundColor: theme.colors.foreground,
|
|
||||||
borderColor: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
actionButtonSecondary: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
actionButtonPressed: {
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
actionButtonDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
actionLabel: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
actionLabelPrimary: {
|
|
||||||
color: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
actionLabelSecondary: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -9,8 +9,6 @@ import type { GitHubSearchItem } from "@server/shared/messages";
|
|||||||
import { Composer } from "./composer";
|
import { Composer } from "./composer";
|
||||||
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
||||||
|
|
||||||
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
imageMetadata,
|
imageMetadata,
|
||||||
@@ -327,9 +325,7 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
|
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
|
||||||
useKeyboardActionHandler: (input: unknown) => {
|
useKeyboardActionHandler: () => {},
|
||||||
keyboardActionHandlerMock(input);
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
@@ -344,7 +340,7 @@ vi.mock("@/contexts/toast-context", () => ({
|
|||||||
useToast: () => ({ error: vi.fn() }),
|
useToast: () => ({ error: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/scroll-jank", () => ({
|
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||||
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||||
markScrollInvestigationEvent: vi.fn(),
|
markScrollInvestigationEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -579,10 +575,6 @@ beforeEach(() => {
|
|||||||
vi.stubGlobal("Node", dom.window.Node);
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
vi.stubGlobal("Blob", dom.window.Blob);
|
vi.stubGlobal("Blob", dom.window.Blob);
|
||||||
Object.assign(dom.window.HTMLElement.prototype, {
|
|
||||||
attachEvent: vi.fn(),
|
|
||||||
detachEvent: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -601,7 +593,6 @@ beforeEach(() => {
|
|||||||
setAgentStreamTailMock.mockClear();
|
setAgentStreamTailMock.mockClear();
|
||||||
setAgentStreamHeadMock.mockClear();
|
setAgentStreamHeadMock.mockClear();
|
||||||
setQueuedMessagesMock.mockClear();
|
setQueuedMessagesMock.mockClear();
|
||||||
keyboardActionHandlerMock.mockClear();
|
|
||||||
agentDirectoryStatusMock.mockReset();
|
agentDirectoryStatusMock.mockReset();
|
||||||
agentDirectoryStatusMock.mockReturnValue("ready");
|
agentDirectoryStatusMock.mockReturnValue("ready");
|
||||||
mockSessionState.sessions.server.serverInfo = {
|
mockSessionState.sessions.server.serverInfo = {
|
||||||
@@ -615,9 +606,6 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockSessionState.sessions.server.agents = new Map([
|
|
||||||
["agent", { status: "idle", lastUsage: null }],
|
|
||||||
]);
|
|
||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
mockSessionState.sessions.server.queuedMessages = new Map();
|
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||||
@@ -731,61 +719,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
|||||||
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchAgentInterrupt() {
|
|
||||||
act(() => {
|
|
||||||
const registeredHandler = keyboardActionHandlerMock.mock.calls.at(-1)?.[0];
|
|
||||||
registeredHandler?.handle({ id: "agent.interrupt", scope: "global" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function countMessageInputRenders(): number {
|
function countMessageInputRenders(): number {
|
||||||
return markScrollInvestigationRenderMock.mock.calls.filter(
|
return markScrollInvestigationRenderMock.mock.calls.filter(
|
||||||
([componentId]) => componentId === "MessageInput:server:agent",
|
([componentId]) => componentId === "MessageInput:server:agent",
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Composer keyboard shortcuts", () => {
|
|
||||||
it("interrupts a running agent without clearing a filled draft", async () => {
|
|
||||||
mockSessionState.sessions.server.agents = new Map([
|
|
||||||
["agent", { status: "running", lastUsage: null }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
renderComposer({ initialText: "keep this prompt" });
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
dispatchAgentInterrupt();
|
|
||||||
|
|
||||||
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
|
|
||||||
expect(document.querySelector('[aria-label="Message agent..."]')).toHaveProperty(
|
|
||||||
"value",
|
|
||||||
"keep this prompt",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("interrupts a running agent when the message input is unfocused", async () => {
|
|
||||||
mockSessionState.sessions.server.agents = new Map([
|
|
||||||
["agent", { status: "running", lastUsage: null }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
renderComposer();
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
const input = document.querySelector('[aria-label="Message agent..."]') as HTMLElement | null;
|
|
||||||
input?.blur();
|
|
||||||
dispatchAgentInterrupt();
|
|
||||||
|
|
||||||
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not interrupt when the agent is idle", () => {
|
|
||||||
renderComposer();
|
|
||||||
|
|
||||||
dispatchAgentInterrupt();
|
|
||||||
|
|
||||||
expect(mockClient.cancelAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Composer attachments", () => {
|
describe("Composer attachments", () => {
|
||||||
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
@@ -834,23 +773,6 @@ describe("Composer attachments", () => {
|
|||||||
expect(combobox.dataset.anchor).toBe("attached");
|
expect(combobox.dataset.anchor).toBe("attached");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lazily searches GitHub only after the GitHub combobox opens", async () => {
|
|
||||||
renderComposer();
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
click(queryByTestId("message-input-attach-button")!);
|
|
||||||
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
|
||||||
await findByTestId("composer-github-combobox");
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
|
||||||
cwd: "/repo",
|
|
||||||
query: "",
|
|
||||||
limit: 20,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes the GitHub combobox after selecting an item", async () => {
|
it("closes the GitHub combobox after selecting an item", async () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank";
|
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
@@ -176,7 +176,7 @@ export function Composer({
|
|||||||
toastErrorRef.current = toast.error;
|
toastErrorRef.current = toast.error;
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
|
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||||
const isDictationReady = useIsDictationReady({
|
const isDictationReady = useIsDictationReady({
|
||||||
serverId,
|
serverId,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -525,15 +525,6 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "agent.interrupt":
|
|
||||||
if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!isAgentRunning || isCancellingAgent || !isConnected) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
handleCancelAgent();
|
|
||||||
return true;
|
|
||||||
case "message-input.send":
|
case "message-input.send":
|
||||||
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
||||||
case "message-input.dictation-confirm":
|
case "message-input.dictation-confirm":
|
||||||
@@ -569,13 +560,12 @@ export function Composer({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused],
|
[isPaneFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: keyboardHandlerIdRef.current,
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
actions: [
|
actions: [
|
||||||
"agent.interrupt",
|
|
||||||
"message-input.focus",
|
"message-input.focus",
|
||||||
"message-input.send",
|
"message-input.send",
|
||||||
"message-input.dictation-toggle",
|
"message-input.dictation-toggle",
|
||||||
@@ -650,12 +640,24 @@ export function Composer({
|
|||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|
||||||
// Handle keyboard navigation for command autocomplete.
|
// Handle keyboard navigation for command autocomplete and stop action.
|
||||||
const handleCommandKeyPress = useCallback(
|
const handleCommandKeyPress = useCallback(
|
||||||
(event: { key: string; preventDefault: () => void }) => {
|
(event: { key: string; preventDefault: () => void }) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" &&
|
||||||
|
isAgentRunning &&
|
||||||
|
!hasSendableContent &&
|
||||||
|
!isCancellingAgent &&
|
||||||
|
isConnected
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCancelAgent();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return autocompleteOnKeyPressRef.current(event);
|
return autocompleteOnKeyPressRef.current(event);
|
||||||
},
|
},
|
||||||
[],
|
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelButton = useMemo(
|
const cancelButton = useMemo(
|
||||||
@@ -681,16 +683,16 @@ export function Composer({
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
{agentInterruptKeys ? (
|
{dictationCancelKeys ? (
|
||||||
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null,
|
) : null,
|
||||||
[
|
[
|
||||||
agentInterruptKeys,
|
|
||||||
buttonIconSize,
|
buttonIconSize,
|
||||||
|
dictationCancelKeys,
|
||||||
handleCancelAgent,
|
handleCancelAgent,
|
||||||
hasSendableContent,
|
hasSendableContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
@@ -790,7 +792,7 @@ export function Composer({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: isGithubPickerOpen && isConnected && !!client && cwd.trim().length > 0,
|
enabled: isConnected && !!client && cwd.trim().length > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { xs: 11, sm: 13 },
|
|
||||||
fontWeight: { medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#f44",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const asyncStorage = vi.hoisted(() => ({
|
|
||||||
values: new Map<string, string>(),
|
|
||||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
|
||||||
setItem: vi.fn(async (key: string, value: string) => {
|
|
||||||
asyncStorage.values.set(key, value);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const hostsState = vi.hoisted(() => ({
|
|
||||||
value: [{ serverId: "server-1", label: "Local daemon" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sessionState = vi.hoisted(() => ({
|
|
||||||
value: {
|
|
||||||
sessions: {
|
|
||||||
"server-1": {
|
|
||||||
serverInfo: {
|
|
||||||
serverId: "server-1",
|
|
||||||
hostname: "MacBook",
|
|
||||||
version: "1.2.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const router = vi.hoisted(() => ({
|
|
||||||
navigate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: asyncStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
useRouter: () => router,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => hostsState.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
|
|
||||||
selector(sessionState.value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/app-version", () => ({
|
|
||||||
resolveAppVersion: () => "1.2.3",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
|
||||||
return { X };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
|
|
||||||
|
|
||||||
function Harness() {
|
|
||||||
return (
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<DaemonVersionMismatchCalloutSource />
|
|
||||||
<SidebarCalloutSlot />
|
|
||||||
</SidebarCalloutProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderHarness(root: Root): Promise<void> {
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<Harness />);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DaemonVersionMismatchCalloutSource", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
|
|
||||||
sessionState.value = {
|
|
||||||
sessions: {
|
|
||||||
"server-1": {
|
|
||||||
serverInfo: {
|
|
||||||
serverId: "server-1",
|
|
||||||
hostname: "MacBook",
|
|
||||||
version: "1.2.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
router.navigate.mockClear();
|
|
||||||
asyncStorage.values.clear();
|
|
||||||
asyncStorage.getItem.mockClear();
|
|
||||||
asyncStorage.setItem.mockClear();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers a daemon mismatch sidebar callout", async () => {
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
|
|
||||||
).not.toBeNull();
|
|
||||||
expect(container?.textContent).toContain("Daemon version mismatch");
|
|
||||||
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
|
|
||||||
expect(container?.textContent).toContain("This app is v1.2.3");
|
|
||||||
expect(container?.textContent).toContain(
|
|
||||||
"For the best experience, keep the daemon and client on the same version.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not register a callout when versions match", async () => {
|
|
||||||
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
|
|
||||||
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens host settings from the callout action", async () => {
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
const action = container?.querySelector(
|
|
||||||
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(action).not.toBeNull();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists dismissal for the current app-daemon version pair", async () => {
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
const dismiss = container?.querySelector(
|
|
||||||
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(dismiss).not.toBeNull();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
|
||||||
"@paseo:sidebar-callout-dismissals",
|
|
||||||
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
|
||||||
import { buildSettingsHostRoute } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
type DaemonVersionMismatch = {
|
|
||||||
serverId: string;
|
|
||||||
label: string;
|
|
||||||
appVersion: string;
|
|
||||||
daemonVersion: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
|
|
||||||
const hosts = useHosts();
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
|
||||||
const appVersion = resolveAppVersion();
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!appVersion) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return hosts
|
|
||||||
.map((entry) => ({
|
|
||||||
serverId: entry.serverId,
|
|
||||||
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
|
|
||||||
appVersion,
|
|
||||||
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
|
|
||||||
}))
|
|
||||||
.filter((entry): entry is DaemonVersionMismatch =>
|
|
||||||
isVersionMismatch(entry.appVersion, entry.daemonVersion),
|
|
||||||
);
|
|
||||||
}, [appVersion, hosts, sessions]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
const router = useRouter();
|
|
||||||
const { appVersion, daemonVersion, label, serverId } = mismatch;
|
|
||||||
const openSettings = useStableEvent(() => {
|
|
||||||
router.navigate(buildSettingsHostRoute(serverId));
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return callouts.show({
|
|
||||||
id: `daemon-version-mismatch:${serverId}`,
|
|
||||||
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
|
|
||||||
priority: 300,
|
|
||||||
title: "Daemon version mismatch",
|
|
||||||
description: `${label} is running ${formatVersionWithPrefix(
|
|
||||||
daemonVersion,
|
|
||||||
)}. This app is ${formatVersionWithPrefix(
|
|
||||||
appVersion,
|
|
||||||
)}. For the best experience, keep the daemon and client on the same version.`,
|
|
||||||
variant: "error",
|
|
||||||
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
|
|
||||||
testID: `daemon-version-mismatch-callout-${serverId}`,
|
|
||||||
});
|
|
||||||
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DaemonVersionMismatchCalloutSource() {
|
|
||||||
const mismatches = useDaemonVersionMismatches();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{mismatches.map((mismatch) => (
|
|
||||||
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { DraggableList } from "./draggable-list.web";
|
|
||||||
|
|
||||||
type DndContextProps = {
|
|
||||||
onDragStart?: (event: { active: { id: string } }) => void;
|
|
||||||
onDragCancel?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let latestDndContextProps: DndContextProps | null = null;
|
|
||||||
|
|
||||||
vi.mock("@dnd-kit/core", () => ({
|
|
||||||
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
|
||||||
latestDndContextProps = props;
|
|
||||||
return <div>{children}</div>;
|
|
||||||
},
|
|
||||||
closestCenter: vi.fn(),
|
|
||||||
KeyboardSensor: vi.fn(),
|
|
||||||
PointerSensor: vi.fn(),
|
|
||||||
useSensor: vi.fn(() => ({})),
|
|
||||||
useSensors: vi.fn(() => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dnd-kit/sortable", () => ({
|
|
||||||
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
|
||||||
arrayMove: <T,>(items: T[], from: number, to: number) => {
|
|
||||||
const next = [...items];
|
|
||||||
const [item] = next.splice(from, 1);
|
|
||||||
if (item !== undefined) {
|
|
||||||
next.splice(to, 0, item);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
sortableKeyboardCoordinates: vi.fn(),
|
|
||||||
useSortable: () => ({
|
|
||||||
attributes: {},
|
|
||||||
listeners: {},
|
|
||||||
setNodeRef: vi.fn(),
|
|
||||||
setActivatorNodeRef: vi.fn(),
|
|
||||||
transform: null,
|
|
||||||
transition: undefined,
|
|
||||||
isDragging: false,
|
|
||||||
}),
|
|
||||||
verticalListSortingStrategy: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-web-scrollbar", () => ({
|
|
||||||
useWebScrollViewScrollbar: () => ({
|
|
||||||
onLayout: vi.fn(),
|
|
||||||
onContentSizeChange: vi.fn(),
|
|
||||||
onScroll: vi.fn(),
|
|
||||||
overlay: null,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal(
|
|
||||||
"ResizeObserver",
|
|
||||||
class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
latestDndContextProps = null;
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderList(): void {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<DraggableList
|
|
||||||
data={["alpha", "beta"]}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
onDragEnd={vi.fn()}
|
|
||||||
renderItem={({ item, isActive }) => (
|
|
||||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
scrollEnabled={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemActiveState(item: string): string | null {
|
|
||||||
return (
|
|
||||||
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DraggableList web", () => {
|
|
||||||
it("clears active drag state when a drag is cancelled", () => {
|
|
||||||
renderList();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
|
|
||||||
});
|
|
||||||
expect(getItemActiveState("alpha")).toBe("true");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
latestDndContextProps?.onDragCancel?.();
|
|
||||||
});
|
|
||||||
expect(getItemActiveState("alpha")).toBe("false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -159,16 +159,12 @@ export function DraggableList<T>({
|
|||||||
[data, onDragBegin],
|
[data, onDragBegin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
|
||||||
setActiveId(null);
|
|
||||||
setDragItems(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
clearDragState();
|
setActiveId(null);
|
||||||
|
setDragItems(null);
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||||
@@ -180,7 +176,7 @@ export function DraggableList<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearDragState, items, keyExtractor, onDragEnd],
|
[items, keyExtractor, onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
@@ -211,7 +207,6 @@ export function DraggableList<T>({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToVerticalAxis]}
|
modifiers={[restrictToVerticalAxis]}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragCancel={clearDragState}
|
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
@@ -242,7 +237,6 @@ export function DraggableList<T>({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToVerticalAxis]}
|
modifiers={[restrictToVerticalAxis]}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragCancel={clearDragState}
|
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
|||||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
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 { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -44,7 +40,6 @@ interface FilePreviewBodyProps {
|
|||||||
showDesktopWebScrollbar: boolean;
|
showDesktopWebScrollbar: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
imagePreviewUri: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -65,34 +60,6 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
|
|
||||||
file: ExplorerFile | null;
|
|
||||||
imageAttachment: AttachmentMetadata | null;
|
|
||||||
}> {
|
|
||||||
if (!file || file.kind !== "image" || !file.content) {
|
|
||||||
return { file, imageAttachment: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content: _content, ...imageFile } = file;
|
|
||||||
const imageAttachment = await persistAttachmentFromBase64({
|
|
||||||
id: createPreviewAttachmentId({
|
|
||||||
mimeType: file.mimeType ?? "image/png",
|
|
||||||
path: file.path,
|
|
||||||
size: file.size,
|
|
||||||
modifiedAt: file.modifiedAt,
|
|
||||||
contentLength: file.content.length,
|
|
||||||
}),
|
|
||||||
base64: file.content,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileName: getFileNameFromPath(file.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
file: imageFile,
|
|
||||||
imageAttachment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({
|
||||||
tokens,
|
tokens,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
@@ -149,7 +116,6 @@ function FilePreviewBody({
|
|||||||
showDesktopWebScrollbar,
|
showDesktopWebScrollbar,
|
||||||
isMobile,
|
isMobile,
|
||||||
filePath,
|
filePath,
|
||||||
imagePreviewUri,
|
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colorScheme === "dark";
|
||||||
@@ -264,16 +230,7 @@ function FilePreviewBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === "image") {
|
if (preview.kind === "image" && preview.content) {
|
||||||
if (!imagePreviewUri) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centerState}>
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
<Text style={styles.loadingText}>Loading file…</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<View style={styles.previewScrollContainer}>
|
||||||
<RNScrollView
|
<RNScrollView
|
||||||
@@ -288,7 +245,7 @@ function FilePreviewBody({
|
|||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
uri: imagePreviewUri,
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||||
}}
|
}}
|
||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
@@ -335,17 +292,11 @@ export function FilePane({
|
|||||||
normalizedFilePath,
|
normalizedFilePath,
|
||||||
"file",
|
"file",
|
||||||
);
|
);
|
||||||
const preview = await createFilePanePreview(payload.file ?? null);
|
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||||
return {
|
|
||||||
file: preview.file,
|
|
||||||
imageAttachment: preview.imageAttachment,
|
|
||||||
error: payload.error ?? null,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
@@ -361,7 +312,6 @@ export function FilePane({
|
|||||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
filePath={filePath}
|
filePath={filePath}
|
||||||
imagePreviewUri={imagePreviewUri}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface ScreenHeaderProps {
|
|||||||
leftStyle?: StyleProp<ViewStyle>;
|
leftStyle?: StyleProp<ViewStyle>;
|
||||||
rightStyle?: StyleProp<ViewStyle>;
|
rightStyle?: StyleProp<ViewStyle>;
|
||||||
borderless?: boolean;
|
borderless?: boolean;
|
||||||
windowControlsPaddingRole?: "header" | "detailHeader";
|
|
||||||
onRowLayout?: (event: LayoutChangeEvent) => void;
|
onRowLayout?: (event: LayoutChangeEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +31,12 @@ export function ScreenHeader({
|
|||||||
leftStyle,
|
leftStyle,
|
||||||
rightStyle,
|
rightStyle,
|
||||||
borderless,
|
borderless,
|
||||||
windowControlsPaddingRole = "header",
|
|
||||||
onRowLayout,
|
onRowLayout,
|
||||||
}: ScreenHeaderProps) {
|
}: ScreenHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const padding = useWindowControlsPadding(windowControlsPaddingRole);
|
const padding = useWindowControlsPadding("header");
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
|
||||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
if (!storeReady) {
|
if (!storeReady) {
|
||||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,67 +1,66 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
|
||||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
|
||||||
memo,
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet as RNStyleSheet,
|
|
||||||
Text,
|
Text,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
StyleSheet as RNStyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Extrapolation,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation,
|
||||||
|
runOnJS,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { router, usePathname } from "expo-router";
|
||||||
|
import {
|
||||||
|
usePanelStore,
|
||||||
|
selectIsAgentListOpen,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
} from "@/stores/panel-store";
|
||||||
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
|
||||||
useSidebarWorkspacesList,
|
useSidebarWorkspacesList,
|
||||||
|
type SidebarProjectEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import {
|
|
||||||
MAX_SIDEBAR_WIDTH,
|
|
||||||
MIN_SIDEBAR_WIDTH,
|
|
||||||
selectIsAgentListOpen,
|
|
||||||
usePanelStore,
|
|
||||||
} from "@/stores/panel-store";
|
|
||||||
import { resolveActiveHost } from "@/utils/active-host";
|
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -380,10 +379,6 @@ function MobileSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
|
||||||
closeToAgent();
|
|
||||||
}, [closeToAgent]);
|
|
||||||
|
|
||||||
const closeGesture = useMemo(
|
const closeGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
@@ -518,7 +513,7 @@ function MobileSidebar({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={() => closeToAgent()}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
@@ -715,8 +710,6 @@ function DesktopSidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarCalloutSlot />
|
|
||||||
|
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
<View style={styles.footerHostSlot}>
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ import { formatShortcut } from "@/utils/format-shortcut";
|
|||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { markScrollInvestigationEvent, markScrollInvestigationRender } from "@/utils/scroll-jank";
|
import {
|
||||||
|
markScrollInvestigationEvent,
|
||||||
|
markScrollInvestigationRender,
|
||||||
|
} from "@/utils/scroll-jank-investigation";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
|
||||||
@@ -304,9 +307,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
if (action === "dictation-cancel") {
|
if (action === "dictation-cancel") {
|
||||||
if (isDictatingRef.current) {
|
if (isDictatingRef.current) {
|
||||||
cancelDictation();
|
cancelDictation();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "dictation-toggle") {
|
if (action === "dictation-toggle") {
|
||||||
|
|||||||
@@ -71,24 +71,18 @@ import {
|
|||||||
} from "@/utils/inline-path";
|
} from "@/utils/inline-path";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank";
|
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import {
|
import {
|
||||||
getAssistantImageMetadata,
|
getAssistantImageMetadata,
|
||||||
setAssistantImageMetadata,
|
setAssistantImageMetadata,
|
||||||
} from "@/utils/assistant-image-metadata";
|
} from "@/utils/assistant-image-metadata";
|
||||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||||
import {
|
|
||||||
createPreviewAttachmentId,
|
|
||||||
getFileNameFromPath,
|
|
||||||
parseImageDataUrl,
|
|
||||||
} from "@/attachments/utils";
|
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -289,7 +283,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
cursor: "auto",
|
|
||||||
},
|
},
|
||||||
containerSpacing: {
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
@@ -439,7 +432,6 @@ interface AssistantMessageProps {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
client?: DaemonClient | null;
|
client?: DaemonClient | null;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
@@ -447,12 +439,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
},
|
||||||
containerCompactTop: {
|
|
||||||
paddingTop: 0,
|
|
||||||
},
|
|
||||||
containerCompactBottom: {
|
|
||||||
paddingBottom: 0,
|
|
||||||
},
|
|
||||||
containerSpacing: {
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
@@ -601,7 +587,6 @@ function AssistantMarkdownImage({
|
|||||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||||
[source, workspaceRoot],
|
[source, workspaceRoot],
|
||||||
);
|
);
|
||||||
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
|
||||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => ({
|
() => ({
|
||||||
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
||||||
@@ -628,49 +613,16 @@ function AssistantMarkdownImage({
|
|||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
const file = payload.file;
|
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
|
||||||
if (!file || file.kind !== "image" || !file.content) {
|
|
||||||
throw new Error("Image preview unavailable.");
|
throw new Error("Image preview unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await persistAttachmentFromBase64({
|
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
|
||||||
id: createPreviewAttachmentId({
|
|
||||||
mimeType: file.mimeType ?? "image/png",
|
|
||||||
path: file.path || resolution.path,
|
|
||||||
size: file.size,
|
|
||||||
modifiedAt: file.modifiedAt,
|
|
||||||
contentLength: file.content.length,
|
|
||||||
}),
|
|
||||||
base64: file.content,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const dataImageQuery = useQuery({
|
|
||||||
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
|
|
||||||
enabled: dataImage !== null,
|
|
||||||
staleTime: 30_000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!dataImage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await persistAttachmentFromDataUrl({
|
|
||||||
id: createPreviewAttachmentId({
|
|
||||||
mimeType: dataImage.mimeType,
|
|
||||||
contentLength: dataImage.base64.length,
|
|
||||||
}),
|
|
||||||
dataUrl: source,
|
|
||||||
mimeType: dataImage.mimeType,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileAssetUri = useAttachmentPreviewUrl(query.data);
|
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
|
||||||
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
|
const resolvedUri = directUri ?? query.data ?? null;
|
||||||
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
|
||||||
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
|
|
||||||
|
|
||||||
if (resolvedUri) {
|
if (resolvedUri) {
|
||||||
return (
|
return (
|
||||||
@@ -685,7 +637,7 @@ function AssistantMarkdownImage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.isLoading || dataImageQuery.isLoading) {
|
if (query.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -708,11 +660,7 @@ function AssistantMarkdownImage({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||||
{query.error instanceof Error
|
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
|
||||||
? query.error.message
|
|
||||||
: dataImageQuery.error instanceof Error
|
|
||||||
? dataImageQuery.error.message
|
|
||||||
: "Unable to load image preview."}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1003,116 +951,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface NativeExpandableBadgeShimmerProps {
|
|
||||||
label: string;
|
|
||||||
secondaryLabel?: string;
|
|
||||||
rowWidth: number;
|
|
||||||
rowHeight: number;
|
|
||||||
peakWidth: number;
|
|
||||||
durationSeconds: number;
|
|
||||||
gradientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({
|
|
||||||
label,
|
|
||||||
secondaryLabel,
|
|
||||||
rowWidth,
|
|
||||||
rowHeight,
|
|
||||||
peakWidth,
|
|
||||||
durationSeconds,
|
|
||||||
gradientId,
|
|
||||||
}: NativeExpandableBadgeShimmerProps) {
|
|
||||||
const shimmerTranslateX = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const startPosition = -peakWidth;
|
|
||||||
const endPosition = rowWidth + peakWidth;
|
|
||||||
shimmerTranslateX.value = startPosition;
|
|
||||||
shimmerTranslateX.value = withRepeat(
|
|
||||||
withTiming(endPosition, {
|
|
||||||
duration: durationSeconds * 1000,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelAnimation(shimmerTranslateX);
|
|
||||||
};
|
|
||||||
}, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]);
|
|
||||||
|
|
||||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: shimmerTranslateX.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nativeShimmerTrackStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }],
|
|
||||||
[rowHeight, rowWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeShimmerMaskStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }],
|
|
||||||
[rowHeight, rowWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeLabelMaskStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeSecondaryMaskStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeShimmerPeakCombinedStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
expandableBadgeStylesheet.nativeShimmerPeak,
|
|
||||||
nativeShimmerPeakStyle,
|
|
||||||
{ width: peakWidth, height: rowHeight },
|
|
||||||
],
|
|
||||||
[nativeShimmerPeakStyle, peakWidth, rowHeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
|
||||||
<MaskedView
|
|
||||||
pointerEvents="none"
|
|
||||||
style={nativeShimmerTrackStyle}
|
|
||||||
maskElement={
|
|
||||||
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
|
|
||||||
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{secondaryLabel ? (
|
|
||||||
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
|
||||||
{secondaryLabel}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={expandableBadgeStylesheet.spacer} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
|
|
||||||
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
|
|
||||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
|
||||||
<Defs>
|
|
||||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
|
||||||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
|
||||||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
|
||||||
</SvgLinearGradient>
|
|
||||||
</Defs>
|
|
||||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
|
||||||
</Svg>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</MaskedView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MemoizedMarkdownBlockProps {
|
interface MemoizedMarkdownBlockProps {
|
||||||
text: string;
|
text: string;
|
||||||
styles: ReturnType<typeof createMarkdownStyles>;
|
styles: ReturnType<typeof createMarkdownStyles>;
|
||||||
@@ -1150,12 +988,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
spacing = "default",
|
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
disableOuterSpacing ?? spacing !== "default",
|
|
||||||
);
|
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
@@ -1356,10 +1191,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
testID="assistant-message"
|
testID="assistant-message"
|
||||||
style={[
|
style={[
|
||||||
assistantMessageStylesheet.container,
|
assistantMessageStylesheet.container,
|
||||||
(spacing === "compactTop" || spacing === "compactBoth") &&
|
|
||||||
assistantMessageStylesheet.containerCompactTop,
|
|
||||||
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
|
||||||
assistantMessageStylesheet.containerCompactBottom,
|
|
||||||
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -1838,6 +1669,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const [labelWidth, setLabelWidth] = useState(0);
|
const [labelWidth, setLabelWidth] = useState(0);
|
||||||
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
||||||
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
||||||
|
const shimmerTranslateX = useSharedValue(0);
|
||||||
|
|
||||||
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
||||||
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
||||||
@@ -1905,6 +1737,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
ensureWebToolCallShimmerKeyframes();
|
ensureWebToolCallShimmerKeyframes();
|
||||||
}, [isWebShimmer]);
|
}, [isWebShimmer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNativeShimmer) {
|
||||||
|
cancelAnimation(shimmerTranslateX);
|
||||||
|
shimmerTranslateX.value = -nativeShimmerPeakWidth;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startPosition = -nativeShimmerPeakWidth;
|
||||||
|
const endPosition = labelRowWidth + nativeShimmerPeakWidth;
|
||||||
|
shimmerTranslateX.value = startPosition;
|
||||||
|
shimmerTranslateX.value = withRepeat(
|
||||||
|
withTiming(endPosition, {
|
||||||
|
duration: shimmerDuration * 1000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(shimmerTranslateX);
|
||||||
|
};
|
||||||
|
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNative || !isExpanded || !hasDetailContent) {
|
if (isNative || !isExpanded || !hasDetailContent) {
|
||||||
return;
|
return;
|
||||||
@@ -1929,6 +1783,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
};
|
};
|
||||||
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
||||||
|
|
||||||
|
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: shimmerTranslateX.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
const shimmerGradient =
|
const shimmerGradient =
|
||||||
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
||||||
|
|
||||||
@@ -2028,6 +1886,41 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
[shimmerSecondaryStyle],
|
[shimmerSecondaryStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nativeShimmerTrackStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
expandableBadgeStylesheet.nativeShimmerTrack,
|
||||||
|
{ width: labelRowWidth, height: labelRowHeight },
|
||||||
|
],
|
||||||
|
[labelRowHeight, labelRowWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeShimmerMaskStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
expandableBadgeStylesheet.shimmerMaskRow,
|
||||||
|
{ width: labelRowWidth, height: labelRowHeight },
|
||||||
|
],
|
||||||
|
[labelRowHeight, labelRowWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeLabelMaskStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeSecondaryMaskStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeShimmerPeakCombinedStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
expandableBadgeStylesheet.nativeShimmerPeak,
|
||||||
|
nativeShimmerPeakStyle,
|
||||||
|
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
||||||
|
],
|
||||||
|
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
|
||||||
|
);
|
||||||
|
|
||||||
const chevronStyle = useMemo(
|
const chevronStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
expandableBadgeStylesheet.chevron,
|
expandableBadgeStylesheet.chevron,
|
||||||
@@ -2109,15 +2002,52 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{isNativeShimmer ? (
|
{isNativeShimmer ? (
|
||||||
<NativeExpandableBadgeShimmer
|
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||||
label={label}
|
<MaskedView
|
||||||
secondaryLabel={secondaryLabel}
|
style={nativeShimmerTrackStyle}
|
||||||
rowWidth={labelRowWidth}
|
maskElement={
|
||||||
rowHeight={labelRowHeight}
|
<View style={nativeShimmerMaskStyle}>
|
||||||
peakWidth={nativeShimmerPeakWidth}
|
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||||||
durationSeconds={shimmerDuration}
|
{label}
|
||||||
gradientId={nativeGradientIdRef.current}
|
</Text>
|
||||||
/>
|
{secondaryLabel ? (
|
||||||
|
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
||||||
|
{secondaryLabel}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={expandableBadgeStylesheet.spacer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={nativeShimmerTrackStyle}>
|
||||||
|
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
||||||
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient
|
||||||
|
id={nativeGradientIdRef.current}
|
||||||
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="0%"
|
||||||
|
>
|
||||||
|
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||||||
|
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
fill={`url(#${nativeGradientIdRef.current})`}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</MaskedView>
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{isInteractive && isHovered ? (
|
{isInteractive && isHovered ? (
|
||||||
@@ -2150,9 +2080,10 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
|||||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
if (previous.testID !== next.testID) return false;
|
if (previous.testID !== next.testID) return false;
|
||||||
if (previous.onToggle !== next.onToggle) return false;
|
if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false;
|
||||||
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
if (previous.isExpanded && previous.renderDetails !== next.renderDetails) {
|
||||||
if (previous.renderDetails !== next.renderDetails) return false;
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./sidebar-callout-slot.native";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function SidebarCalloutSlot() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act, useEffect } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { xs: 11, sm: 13 },
|
|
||||||
fontWeight: { medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#f44",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const asyncStorage = vi.hoisted(() => ({
|
|
||||||
getItem: vi.fn(async () => null),
|
|
||||||
setItem: vi.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: asyncStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
|
||||||
return { X };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import { SidebarCalloutProvider, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
|
||||||
|
|
||||||
function RegisterCallout() {
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
useEffect(
|
|
||||||
() =>
|
|
||||||
callouts.show({
|
|
||||||
id: "slot-test",
|
|
||||||
title: "Update available",
|
|
||||||
description: "v1 is ready.",
|
|
||||||
testID: "slot-test-callout",
|
|
||||||
}),
|
|
||||||
[callouts],
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SidebarCalloutSlot", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the active sidebar callout", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<RegisterCallout />
|
|
||||||
<SidebarCalloutSlot />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
expect(container?.textContent).toContain("v1 is ready.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing when there is no active callout", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<SidebarCalloutSlot />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="slot-test-callout"]')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { View } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
|
|
||||||
|
|
||||||
export function SidebarCalloutSlot() {
|
|
||||||
return (
|
|
||||||
<View style={styles.slot} collapsable={false}>
|
|
||||||
<SidebarCalloutViewport />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create(() => ({
|
|
||||||
slot: {
|
|
||||||
width: "100%",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -62,7 +62,6 @@ const runningScript: WorkspaceScriptPayload = {
|
|||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
terminalId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function workspace(input: {
|
function workspace(input: {
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { SortableInlineList } from "./sortable-inline-list.web";
|
|
||||||
|
|
||||||
type DndContextProps = {
|
|
||||||
onDragStart?: (event: { active: { id: string } }) => void;
|
|
||||||
onDragCancel?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let latestDndContextProps: DndContextProps | null = null;
|
|
||||||
|
|
||||||
vi.mock("@dnd-kit/core", () => ({
|
|
||||||
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
|
||||||
latestDndContextProps = props;
|
|
||||||
return <div>{children}</div>;
|
|
||||||
},
|
|
||||||
closestCenter: vi.fn(),
|
|
||||||
KeyboardSensor: vi.fn(),
|
|
||||||
PointerSensor: vi.fn(),
|
|
||||||
useSensor: vi.fn(() => ({})),
|
|
||||||
useSensors: vi.fn(() => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dnd-kit/sortable", () => ({
|
|
||||||
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
|
||||||
arrayMove: <T,>(items: T[], from: number, to: number) => {
|
|
||||||
const next = [...items];
|
|
||||||
const [item] = next.splice(from, 1);
|
|
||||||
if (item !== undefined) {
|
|
||||||
next.splice(to, 0, item);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
horizontalListSortingStrategy: {},
|
|
||||||
sortableKeyboardCoordinates: vi.fn(),
|
|
||||||
useSortable: () => ({
|
|
||||||
attributes: {},
|
|
||||||
listeners: {},
|
|
||||||
setNodeRef: vi.fn(),
|
|
||||||
setActivatorNodeRef: vi.fn(),
|
|
||||||
transform: null,
|
|
||||||
transition: undefined,
|
|
||||||
isDragging: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
latestDndContextProps = null;
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderList(): void {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<SortableInlineList
|
|
||||||
data={["alpha", "beta"]}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
onDragEnd={vi.fn()}
|
|
||||||
renderItem={({ item, isActive }) => (
|
|
||||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemActiveState(item: string): string | null {
|
|
||||||
return (
|
|
||||||
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SortableInlineList web", () => {
|
|
||||||
it("clears active drag state when a drag is cancelled", () => {
|
|
||||||
renderList();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
|
|
||||||
});
|
|
||||||
expect(getItemActiveState("alpha")).toBe("true");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
latestDndContextProps?.onDragCancel?.();
|
|
||||||
});
|
|
||||||
expect(getItemActiveState("alpha")).toBe("false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -155,16 +155,12 @@ export function SortableInlineList<T>({
|
|||||||
[data, disabled, onDragBegin],
|
[data, disabled, onDragBegin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
|
||||||
setActiveId(null);
|
|
||||||
setDragItems(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
clearDragState();
|
setActiveId(null);
|
||||||
|
setDragItems(null);
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
@@ -180,7 +176,7 @@ export function SortableInlineList<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearDragState, disabled, items, keyExtractor, onDragEnd],
|
[disabled, items, keyExtractor, onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
@@ -217,7 +213,6 @@ export function SortableInlineList<T>({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToHorizontalAxis]}
|
modifiers={[restrictToHorizontalAxis]}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragCancel={clearDragState}
|
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{renderedItems}
|
{renderedItems}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +37,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
baseListContentContainerStyle,
|
baseListContentContainerStyle,
|
||||||
strategy,
|
strategy,
|
||||||
} = props;
|
} = props;
|
||||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
|
||||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
const streamViewportMetricsRef = useRef({
|
const streamViewportMetricsRef = useRef({
|
||||||
containerKey: "native-virtualized",
|
containerKey: "native-virtualized",
|
||||||
@@ -192,91 +190,105 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
};
|
};
|
||||||
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
||||||
|
|
||||||
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const handleScroll = useCallback(
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const previousOffsetY = scrollOffsetYRef.current;
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
scrollOffsetYRef.current = contentOffset.y;
|
const previousOffsetY = scrollOffsetYRef.current;
|
||||||
streamViewportMetricsRef.current = {
|
scrollOffsetYRef.current = contentOffset.y;
|
||||||
contentHeight: Math.max(0, contentSize.height),
|
streamViewportMetricsRef.current = {
|
||||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
contentHeight: Math.max(0, contentSize.height),
|
||||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||||
containerKey: "native-virtualized",
|
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||||
offsetY: contentOffset.y,
|
containerKey: "native-virtualized",
|
||||||
viewportMeasuredForKey: "native-virtualized",
|
offsetY: contentOffset.y,
|
||||||
contentMeasuredForKey: "native-virtualized",
|
viewportMeasuredForKey: "native-virtualized",
|
||||||
};
|
contentMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
|
||||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||||
strategy,
|
strategy,
|
||||||
offsetY: contentOffset.y,
|
offsetY: contentOffset.y,
|
||||||
threshold: 32,
|
threshold: 32,
|
||||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||||
});
|
|
||||||
onNearBottomChange(nearBottom);
|
|
||||||
|
|
||||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
|
||||||
programmaticScrollEventBudgetRef.current -= 1;
|
|
||||||
} else {
|
|
||||||
programmaticScrollEventBudgetRef.current = 0;
|
|
||||||
bottomAnchorController.handleScrollNearBottomChange({
|
|
||||||
nextIsNearBottom: nearBottom,
|
|
||||||
scrollDelta: contentOffset.y - previousOffsetY,
|
|
||||||
});
|
});
|
||||||
}
|
onNearBottomChange(nearBottom);
|
||||||
});
|
|
||||||
|
|
||||||
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
programmaticScrollEventBudgetRef.current -= 1;
|
||||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
} else {
|
||||||
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
programmaticScrollEventBudgetRef.current = 0;
|
||||||
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
bottomAnchorController.handleScrollNearBottomChange({
|
||||||
const viewportChanged =
|
nextIsNearBottom: nearBottom,
|
||||||
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
scrollDelta: contentOffset.y - previousOffsetY,
|
||||||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
});
|
||||||
streamViewportMetricsRef.current = {
|
}
|
||||||
...streamViewportMetricsRef.current,
|
},
|
||||||
containerKey: "native-virtualized",
|
[bottomAnchorController, onNearBottomChange, strategy],
|
||||||
viewportWidth,
|
);
|
||||||
viewportHeight,
|
|
||||||
viewportMeasuredForKey: "native-virtualized",
|
|
||||||
};
|
|
||||||
if (viewportChanged) {
|
|
||||||
markNativeViewportSettling();
|
|
||||||
}
|
|
||||||
bottomAnchorController.handleViewportMetricsChange({
|
|
||||||
previousViewportWidth,
|
|
||||||
viewportWidth,
|
|
||||||
previousViewportHeight,
|
|
||||||
viewportHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
|
const handleListLayout = useCallback(
|
||||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
(event: LayoutChangeEvent) => {
|
||||||
const nextContentHeight = Math.max(0, height);
|
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||||
streamViewportMetricsRef.current = {
|
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||||
...streamViewportMetricsRef.current,
|
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
||||||
containerKey: "native-virtualized",
|
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
||||||
contentHeight: nextContentHeight,
|
const viewportChanged =
|
||||||
contentMeasuredForKey: "native-virtualized",
|
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
||||||
};
|
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
||||||
bottomAnchorController.handleContentSizeChange({
|
streamViewportMetricsRef.current = {
|
||||||
previousContentHeight,
|
...streamViewportMetricsRef.current,
|
||||||
contentHeight: nextContentHeight,
|
containerKey: "native-virtualized",
|
||||||
});
|
viewportWidth,
|
||||||
});
|
viewportHeight,
|
||||||
|
viewportMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
if (viewportChanged) {
|
||||||
|
markNativeViewportSettling();
|
||||||
|
}
|
||||||
|
bottomAnchorController.handleViewportMetricsChange({
|
||||||
|
previousViewportWidth,
|
||||||
|
viewportWidth,
|
||||||
|
previousViewportHeight,
|
||||||
|
viewportHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[bottomAnchorController, markNativeViewportSettling],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useStableEvent(({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
const handleContentSizeChange = useCallback(
|
||||||
const rendered = renderHistoryMountedRow(item, index, historyRows);
|
(_width: number, height: number) => {
|
||||||
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||||
});
|
const nextContentHeight = Math.max(0, height);
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
contentHeight: nextContentHeight,
|
||||||
|
contentMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
bottomAnchorController.handleContentSizeChange({
|
||||||
|
previousContentHeight,
|
||||||
|
contentHeight: nextContentHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[bottomAnchorController],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||||
|
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
||||||
|
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
||||||
|
},
|
||||||
|
[historyRows, renderers],
|
||||||
|
);
|
||||||
|
|
||||||
const liveHeaderContent = useMemo(() => {
|
const liveHeaderContent = useMemo(() => {
|
||||||
const liveHeadRows = segments.liveHead.map((item, index) => (
|
const liveHeadRows = segments.liveHead.map((item, index) => (
|
||||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
<Fragment key={item.id}>
|
||||||
|
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
|
||||||
|
</Fragment>
|
||||||
));
|
));
|
||||||
const liveAuxiliary = renderLiveAuxiliary();
|
const liveAuxiliary = renderers.renderLiveAuxiliary();
|
||||||
if (
|
if (
|
||||||
liveHeadRows.length === 0 &&
|
liveHeadRows.length === 0 &&
|
||||||
!liveAuxiliary &&
|
!liveAuxiliary &&
|
||||||
@@ -291,7 +303,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
{liveAuxiliary}
|
{liveAuxiliary}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
|
}, [boundary, listEmptyComponent, renderers, segments.liveHead]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -309,11 +321,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||||
initialNumToRender={40}
|
initialNumToRender={12}
|
||||||
maxToRenderPerBatch={40}
|
windowSize={10}
|
||||||
updateCellsBatchingPeriod={0}
|
|
||||||
windowSize={21}
|
|
||||||
removeClippedSubviews={false}
|
|
||||||
scrollEnabled={scrollEnabled}
|
scrollEnabled={scrollEnabled}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
inverted
|
inverted
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { ITheme } from "@xterm/xterm";
|
|||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import { openExternalUrl } from "../utils/open-external-url";
|
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
import { focusWithRetries } from "../utils/web-focus";
|
||||||
import {
|
import {
|
||||||
computeScrollOffsetFromDragDelta,
|
computeScrollOffsetFromDragDelta,
|
||||||
@@ -326,7 +325,6 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onOpenExternalUrl: openExternalUrl,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
runtime.setPendingModifiers({ pendingModifiers });
|
runtime.setPendingModifiers({ pendingModifiers });
|
||||||
@@ -352,7 +350,6 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onOpenExternalUrl: openExternalUrl,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHostRuntimeSnapshot,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -89,6 +94,39 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
actionTextPrimary: {
|
actionTextPrimary: {
|
||||||
color: theme.colors.accentForeground,
|
color: theme.colors.accentForeground,
|
||||||
},
|
},
|
||||||
|
hostList: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
marginTop: theme.spacing[6],
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
hostRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
hostLabel: {
|
||||||
|
flex: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatus: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatusError: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
setupLink: {
|
setupLink: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -151,6 +189,50 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const status = snapshot?.connectionStatus ?? "connecting";
|
||||||
|
const lastError = snapshot?.lastError ?? null;
|
||||||
|
|
||||||
|
let dotColor: string;
|
||||||
|
let statusText: string;
|
||||||
|
let isError = false;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
dotColor = theme.colors.success;
|
||||||
|
statusText = "Online";
|
||||||
|
break;
|
||||||
|
case "connecting":
|
||||||
|
case "idle":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Connecting…";
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Offline";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
dotColor = theme.colors.destructive;
|
||||||
|
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
|
||||||
|
isError = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.hostRow}>
|
||||||
|
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
|
||||||
|
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WelcomeScreenProps {
|
export interface WelcomeScreenProps {
|
||||||
onHostAdded?: (profile: HostProfile) => void;
|
onHostAdded?: (profile: HostProfile) => void;
|
||||||
}
|
}
|
||||||
@@ -224,7 +306,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
@@ -240,11 +322,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PaseoLogo size={96} />
|
<PaseoLogo size={96} />
|
||||||
<View style={styles.copyBlock}>
|
<View style={styles.copyBlock}>
|
||||||
{isConnectingToSavedHosts ? (
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
<Text style={styles.subtitle}>Connecting…</Text>
|
{showHostList ? (
|
||||||
|
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
|
||||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||||
{isNative ? (
|
{isNative ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -283,6 +365,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{showHostList && (
|
||||||
|
<View style={styles.hostList}>
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -132,8 +132,7 @@ function WorkspaceHoverCardDesktop({
|
|||||||
|
|
||||||
const handleTriggerLeave = useCallback(() => {
|
const handleTriggerLeave = useCallback(() => {
|
||||||
triggerHoveredRef.current = false;
|
triggerHoveredRef.current = false;
|
||||||
scheduleClose();
|
}, []);
|
||||||
}, [scheduleClose]);
|
|
||||||
|
|
||||||
// While open, the safe zone covers trigger + content + the bridge between
|
// While open, the safe zone covers trigger + content + the bridge between
|
||||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||||
@@ -156,16 +155,11 @@ function WorkspaceHoverCardDesktop({
|
|||||||
|
|
||||||
// When content becomes available while trigger is already hovered, open the card.
|
// When content becomes available while trigger is already hovered, open the card.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasContent) {
|
if (!hasContent || isDragging) return;
|
||||||
clearGraceTimer();
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isDragging) return;
|
|
||||||
if (triggerHoveredRef.current) {
|
if (triggerHoveredRef.current) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
}, [clearGraceTimer, hasContent, isDragging]);
|
}, [hasContent, isDragging]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const runningScript: WorkspaceScriptPayload = {
|
|||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
terminalId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("patchWorkspaceScripts", () => {
|
describe("patchWorkspaceScripts", () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
|||||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
createSessionAgentStreamReducerQueue,
|
|
||||||
processTimelineResponse,
|
processTimelineResponse,
|
||||||
|
processAgentStreamEvent,
|
||||||
} from "@/contexts/session-stream-reducers";
|
} from "@/contexts/session-stream-reducers";
|
||||||
import type {
|
import type {
|
||||||
ActivityLogPayload,
|
ActivityLogPayload,
|
||||||
@@ -1049,14 +1049,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
applyAgentUpdatePayload(update);
|
applyAgentUpdatePayload(update);
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
|
|
||||||
serverId,
|
|
||||||
setAgentStreamState,
|
|
||||||
setAgentTimelineCursor,
|
|
||||||
setAgents,
|
|
||||||
requestCanonicalCatchUp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||||
if (message.type !== "agent_stream") return;
|
if (message.type !== "agent_stream") return;
|
||||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||||
@@ -1083,13 +1075,96 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
agentStreamReducerQueue.enqueue(agentId, {
|
// Read current store state
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
|
||||||
|
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
|
||||||
|
const currentCursor = session?.agentTimelineCursor.get(agentId);
|
||||||
|
const currentAgentEntry = session?.agents.get(agentId);
|
||||||
|
const currentAgent = currentAgentEntry
|
||||||
|
? {
|
||||||
|
status: currentAgentEntry.status,
|
||||||
|
updatedAt: currentAgentEntry.updatedAt,
|
||||||
|
lastActivityAt: currentAgentEntry.lastActivityAt,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Call pure reducer
|
||||||
|
const result = processAgentStreamEvent({
|
||||||
event: streamEvent,
|
event: streamEvent,
|
||||||
seq,
|
seq,
|
||||||
epoch,
|
epoch,
|
||||||
|
currentTail,
|
||||||
|
currentHead,
|
||||||
|
currentCursor,
|
||||||
|
currentAgent,
|
||||||
timestamp: parsedTimestamp,
|
timestamp: parsedTimestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply tail/head patches
|
||||||
|
if (result.changedTail || result.changedHead) {
|
||||||
|
setAgentStreamState(serverId, agentId, {
|
||||||
|
...(result.changedTail ? { tail: result.tail } : {}),
|
||||||
|
...(result.changedHead ? { head: result.head } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cursor patch
|
||||||
|
if (result.cursorChanged && result.cursor) {
|
||||||
|
const nextCursor = result.cursor;
|
||||||
|
setAgentTimelineCursor(serverId, (prev) => {
|
||||||
|
const current = prev.get(agentId);
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
typeof seq === "number" &&
|
||||||
|
typeof epoch === "string" &&
|
||||||
|
current.epoch === epoch &&
|
||||||
|
seq >= current.startSeq &&
|
||||||
|
seq <= current.endSeq
|
||||||
|
) {
|
||||||
|
// Fast-path: seq stays inside the current range during streaming.
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
current.epoch === nextCursor.epoch &&
|
||||||
|
current.startSeq === nextCursor.startSeq &&
|
||||||
|
current.endSeq === nextCursor.endSeq
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, nextCursor);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply agent patch (optimistic lifecycle)
|
||||||
|
if (result.agentChanged && result.agent) {
|
||||||
|
const nextAgent = result.agent;
|
||||||
|
setAgents(serverId, (prev) => {
|
||||||
|
const current = prev.get(agentId);
|
||||||
|
if (!current) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, {
|
||||||
|
...current,
|
||||||
|
status: nextAgent.status,
|
||||||
|
updatedAt: nextAgent.updatedAt,
|
||||||
|
lastActivityAt: nextAgent.lastActivityAt,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute side effects
|
||||||
|
for (const effect of result.sideEffects) {
|
||||||
|
if (effect.type === "catch_up") {
|
||||||
|
requestCanonicalCatchUp(agentId, effect.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||||
// on status changes, which is sufficient for sorting and display purposes.
|
// on status changes, which is sufficient for sorting and display purposes.
|
||||||
@@ -1097,7 +1172,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
|
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
|
||||||
if (message.type !== "fetch_agent_timeline_response") return;
|
if (message.type !== "fetch_agent_timeline_response") return;
|
||||||
agentStreamReducerQueue.flushAgent(message.payload.agentId);
|
|
||||||
applyTimelineResponse(message.payload);
|
applyTimelineResponse(message.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1498,7 +1572,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
unsubVoiceInputState();
|
unsubVoiceInputState();
|
||||||
unsubAgentDeleted();
|
unsubAgentDeleted();
|
||||||
unsubAgentArchived();
|
unsubAgentArchived();
|
||||||
agentStreamReducerQueue.dispose({ flush: true });
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -13,17 +13,10 @@ const turnFailedEvent: AgentStreamEventPayload = {
|
|||||||
error: "failed",
|
error: "failed",
|
||||||
};
|
};
|
||||||
|
|
||||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_canceled",
|
|
||||||
provider: "codex",
|
|
||||||
reason: "interrupted",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("session stream lifecycle helpers", () => {
|
describe("session stream lifecycle helpers", () => {
|
||||||
it("derives optimistic terminal lifecycle only when current status is running", () => {
|
it("derives optimistic terminal lifecycle only when current status is running", () => {
|
||||||
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
|
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
|
||||||
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
|
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
|
||||||
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
|
|
||||||
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
|
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
|
||||||
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
|
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,13 +10,10 @@ export function deriveOptimisticLifecycleStatus(
|
|||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
|
case "turn_canceled":
|
||||||
return "idle";
|
return "idle";
|
||||||
case "turn_failed":
|
case "turn_failed":
|
||||||
return "error";
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
createAgentStreamReducerQueue,
|
|
||||||
processTimelineResponse,
|
processTimelineResponse,
|
||||||
processAgentStreamEvent,
|
processAgentStreamEvent,
|
||||||
processAgentStreamEvents,
|
|
||||||
type ProcessTimelineResponseInput,
|
type ProcessTimelineResponseInput,
|
||||||
type ProcessAgentStreamEventInput,
|
type ProcessAgentStreamEventInput,
|
||||||
type AgentStreamReducerEvent,
|
|
||||||
type TimelineCursor,
|
type TimelineCursor,
|
||||||
} from "./session-stream-reducers";
|
} from "./session-stream-reducers";
|
||||||
|
|
||||||
@@ -19,7 +16,6 @@ import {
|
|||||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||||
return {
|
return {
|
||||||
seqStart: seq,
|
seqStart: seq,
|
||||||
seqEnd: seq,
|
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: { type, text },
|
item: { type, text },
|
||||||
timestamp: new Date(1000 + seq).toISOString(),
|
timestamp: new Date(1000 + seq).toISOString(),
|
||||||
@@ -45,36 +41,6 @@ function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
|||||||
} as AgentStreamEventPayload;
|
} as AgentStreamEventPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeToolCallTimelineEvent(callId: string): AgentStreamEventPayload {
|
|
||||||
return {
|
|
||||||
type: "timeline",
|
|
||||||
provider: "claude",
|
|
||||||
item: {
|
|
||||||
type: "tool_call",
|
|
||||||
callId,
|
|
||||||
name: "Read",
|
|
||||||
status: "running",
|
|
||||||
detail: {
|
|
||||||
type: "read",
|
|
||||||
filePath: "/tmp/example.ts",
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
} as AgentStreamEventPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeStreamReducerEvent(
|
|
||||||
event: AgentStreamEventPayload,
|
|
||||||
seq: number,
|
|
||||||
): AgentStreamReducerEvent {
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
seq,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
timestamp: new Date(1000 + seq),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -319,39 +285,6 @@ describe("processTimelineResponse", () => {
|
|||||||
expect(result.cursorChanged).toBe(false);
|
expect(result.cursorChanged).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
...makeTimelineEntry(4, "merged assistant message"),
|
|
||||||
seqEnd: 8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
expect(result.sideEffects).toContainEqual({
|
|
||||||
type: "catch_up",
|
|
||||||
cursor: {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops entries with epoch mismatch", () => {
|
it("drops entries with epoch mismatch", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const existingCursor: TimelineCursor = {
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
@@ -636,28 +569,6 @@ describe("processAgentStreamEvent", () => {
|
|||||||
expect(result.agent!.status).toBe("error");
|
expect(result.agent!.status).toBe("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
|
|
||||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_canceled",
|
|
||||||
provider: "codex",
|
|
||||||
reason: "interrupted",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnCanceledEvent,
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(false);
|
|
||||||
expect(result.agent).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not change agent when status is not running", () => {
|
it("does not change agent when status is not running", () => {
|
||||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||||
type: "turn_completed",
|
type: "turn_completed",
|
||||||
@@ -736,270 +647,3 @@ describe("processAgentStreamEvent", () => {
|
|||||||
expect(result.agent).toBe(null);
|
expect(result.agent).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processAgentStreamEvents", () => {
|
|
||||||
it("coalesces contiguous assistant stream events into one head update and final cursor", () => {
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events: [
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("Hello"), 1),
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent(" world"), 2),
|
|
||||||
],
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.changedTail).toBe(false);
|
|
||||||
expect(result.changedHead).toBe(true);
|
|
||||||
expect(result.tail).toEqual([]);
|
|
||||||
expect(result.head).toHaveLength(1);
|
|
||||||
expect(result.head[0]).toMatchObject({
|
|
||||||
kind: "assistant_message",
|
|
||||||
text: "Hello world",
|
|
||||||
});
|
|
||||||
expect(result.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 2,
|
|
||||||
} satisfies TimelineCursor);
|
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes completed assistant markdown blocks to tail while keeping the live block in head", () => {
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events: [
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("First paragraph"), 1),
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("\n\nSecond paragraph"), 2),
|
|
||||||
],
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.changedTail).toBe(true);
|
|
||||||
expect(result.changedHead).toBe(true);
|
|
||||||
expect(result.tail).toHaveLength(1);
|
|
||||||
expect(result.tail[0]).toMatchObject({
|
|
||||||
kind: "assistant_message",
|
|
||||||
text: "First paragraph",
|
|
||||||
});
|
|
||||||
expect(result.head).toHaveLength(1);
|
|
||||||
expect(result.head[0]).toMatchObject({
|
|
||||||
kind: "assistant_message",
|
|
||||||
text: "Second paragraph",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not promote a markdown block that is still inside an open code fence", () => {
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events: [
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("Before fence\n\n```ts\nconst a = 1;"), 1),
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("\n\nconst b = 2;"), 2),
|
|
||||||
],
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.changedTail).toBe(true);
|
|
||||||
expect(result.tail).toHaveLength(1);
|
|
||||||
expect(result.tail[0]).toMatchObject({
|
|
||||||
kind: "assistant_message",
|
|
||||||
text: "Before fence",
|
|
||||||
});
|
|
||||||
expect(result.head).toHaveLength(1);
|
|
||||||
expect(result.head[0]).toMatchObject({
|
|
||||||
kind: "assistant_message",
|
|
||||||
text: "```ts\nconst a = 1;\n\nconst b = 2;",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes the live assistant block before applying a tool call in the same reducer pass", () => {
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events: [
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("Before tool"), 1),
|
|
||||||
makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 2),
|
|
||||||
],
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.changedTail).toBe(true);
|
|
||||||
expect(result.changedHead).toBe(true);
|
|
||||||
expect(result.head).toEqual([]);
|
|
||||||
expect(result.tail.map((item: StreamItem) => item.kind)).toEqual([
|
|
||||||
"assistant_message",
|
|
||||||
"tool_call",
|
|
||||||
]);
|
|
||||||
expect(result.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 2,
|
|
||||||
} satisfies TimelineCursor);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the final optimistic lifecycle patch across a batch", () => {
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events: [
|
|
||||||
makeStreamReducerEvent(makeTimelineEvent("Done"), 1),
|
|
||||||
{
|
|
||||||
event: { type: "turn_completed", provider: "claude" } as AgentStreamEventPayload,
|
|
||||||
seq: undefined,
|
|
||||||
epoch: undefined,
|
|
||||||
timestamp: new Date(3000),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.head).toEqual([]);
|
|
||||||
expect(result.tail).toHaveLength(1);
|
|
||||||
expect(result.agentChanged).toBe(true);
|
|
||||||
expect(result.agent).toMatchObject({
|
|
||||||
status: "idle",
|
|
||||||
updatedAt: new Date(3000),
|
|
||||||
lastActivityAt: new Date(3000),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createAgentStreamReducerQueue", () => {
|
|
||||||
function createManualScheduler() {
|
|
||||||
let nextId = 1;
|
|
||||||
const callbacks = new Map<number, () => void>();
|
|
||||||
return {
|
|
||||||
schedule(callback: () => void) {
|
|
||||||
const id = nextId;
|
|
||||||
nextId += 1;
|
|
||||||
callbacks.set(id, callback);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
cancel(id: number) {
|
|
||||||
callbacks.delete(id);
|
|
||||||
},
|
|
||||||
flushOne() {
|
|
||||||
const entry = callbacks.entries().next().value;
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error("Expected a scheduled callback");
|
|
||||||
}
|
|
||||||
const [id, callback] = entry;
|
|
||||||
callbacks.delete(id);
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
get size() {
|
|
||||||
return callbacks.size;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("coalesces multiple events for one agent into one scheduled commit", () => {
|
|
||||||
const scheduler = createManualScheduler();
|
|
||||||
const commits: Array<{ agentId: string; headText: string; cursorEndSeq: number | null }> = [];
|
|
||||||
let currentTail: StreamItem[] = [];
|
|
||||||
let currentHead: StreamItem[] = [];
|
|
||||||
|
|
||||||
const queue = createAgentStreamReducerQueue({
|
|
||||||
getSnapshot: () => ({
|
|
||||||
currentTail,
|
|
||||||
currentHead,
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
}),
|
|
||||||
commit: (agentId, result) => {
|
|
||||||
currentTail = result.tail;
|
|
||||||
currentHead = result.head;
|
|
||||||
commits.push({
|
|
||||||
agentId,
|
|
||||||
headText: result.head[0]?.kind === "assistant_message" ? result.head[0].text : "",
|
|
||||||
cursorEndSeq: result.cursor?.endSeq ?? null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleSideEffects: () => {},
|
|
||||||
scheduleFlush: scheduler.schedule,
|
|
||||||
cancelFlush: scheduler.cancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("Hello"), 1));
|
|
||||||
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent(" world"), 2));
|
|
||||||
|
|
||||||
expect(scheduler.size).toBe(1);
|
|
||||||
expect(commits).toEqual([]);
|
|
||||||
|
|
||||||
scheduler.flushOne();
|
|
||||||
|
|
||||||
expect(commits).toEqual([
|
|
||||||
{
|
|
||||||
agentId: "agent-1",
|
|
||||||
headText: "Hello world",
|
|
||||||
cursorEndSeq: 2,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(scheduler.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes queued events synchronously for one agent before canonical history is applied", () => {
|
|
||||||
const scheduler = createManualScheduler();
|
|
||||||
const commits: string[] = [];
|
|
||||||
const queue = createAgentStreamReducerQueue({
|
|
||||||
getSnapshot: () => ({
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
}),
|
|
||||||
commit: (agentId, result) => {
|
|
||||||
commits.push(
|
|
||||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
handleSideEffects: () => {},
|
|
||||||
scheduleFlush: scheduler.schedule,
|
|
||||||
cancelFlush: scheduler.cancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("queued"), 1));
|
|
||||||
queue.flushAgent("agent-1");
|
|
||||||
|
|
||||||
expect(commits).toEqual(["agent-1:queued"]);
|
|
||||||
expect(scheduler.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes queued events synchronously before disposal", () => {
|
|
||||||
const scheduler = createManualScheduler();
|
|
||||||
const commits: string[] = [];
|
|
||||||
const queue = createAgentStreamReducerQueue({
|
|
||||||
getSnapshot: () => ({
|
|
||||||
currentTail: [],
|
|
||||||
currentHead: [],
|
|
||||||
currentCursor: undefined,
|
|
||||||
currentAgent: null,
|
|
||||||
}),
|
|
||||||
commit: (agentId, result) => {
|
|
||||||
commits.push(
|
|
||||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
handleSideEffects: () => {},
|
|
||||||
scheduleFlush: scheduler.schedule,
|
|
||||||
cancelFlush: scheduler.cancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("queued"), 1));
|
|
||||||
queue.dispose({ flush: true });
|
|
||||||
|
|
||||||
expect(commits).toEqual(["agent-1:queued"]);
|
|
||||||
expect(scheduler.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { Agent } from "@/stores/session-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
@@ -19,8 +17,6 @@ import {
|
|||||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||||
|
|
||||||
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared cursor type
|
// Shared cursor type
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -53,7 +49,6 @@ type InitRequestDirection = "tail" | "after";
|
|||||||
|
|
||||||
type TimelineResponseEntry = {
|
type TimelineResponseEntry = {
|
||||||
seqStart: number;
|
seqStart: number;
|
||||||
seqEnd: number;
|
|
||||||
provider: string;
|
provider: string;
|
||||||
item: Record<string, unknown>;
|
item: Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -123,7 +118,6 @@ export function processTimelineResponse(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const timelineUnits = payload.entries.map((entry) => ({
|
const timelineUnits = payload.entries.map((entry) => ({
|
||||||
seq: entry.seqStart,
|
seq: entry.seqStart,
|
||||||
seqEnd: entry.seqEnd,
|
|
||||||
event: {
|
event: {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: entry.provider,
|
provider: entry.provider,
|
||||||
@@ -202,14 +196,7 @@ export function processTimelineResponse(
|
|||||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (decision === "drop_stale") {
|
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||||
if (cursor && unit.seqEnd > cursor.endSeq) {
|
|
||||||
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (decision === "drop_epoch") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +205,7 @@ export function processTimelineResponse(
|
|||||||
cursor = {
|
cursor = {
|
||||||
epoch: payload.epoch,
|
epoch: payload.epoch,
|
||||||
startSeq: unit.seq,
|
startSeq: unit.seq,
|
||||||
endSeq: unit.seqEnd,
|
endSeq: unit.seq,
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -227,7 +214,7 @@ export function processTimelineResponse(
|
|||||||
}
|
}
|
||||||
cursor = {
|
cursor = {
|
||||||
...cursor,
|
...cursor,
|
||||||
endSeq: unit.seqEnd,
|
endSeq: unit.seq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,62 +324,6 @@ export interface ProcessAgentStreamEventOutput {
|
|||||||
sideEffects: AgentStreamReducerSideEffect[];
|
sideEffects: AgentStreamReducerSideEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentStreamReducerEvent = {
|
|
||||||
event: AgentStreamEventPayload;
|
|
||||||
seq: number | undefined;
|
|
||||||
epoch: string | undefined;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentStreamReducerAgentSnapshot = {
|
|
||||||
status: AgentLifecycleStatus;
|
|
||||||
updatedAt: Date;
|
|
||||||
lastActivityAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProcessAgentStreamEventsInput = {
|
|
||||||
events: AgentStreamReducerEvent[];
|
|
||||||
currentTail: StreamItem[];
|
|
||||||
currentHead: StreamItem[];
|
|
||||||
currentCursor: TimelineCursor | undefined;
|
|
||||||
currentAgent: AgentStreamReducerAgentSnapshot | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
|
|
||||||
|
|
||||||
export type AgentStreamReducerQueue = {
|
|
||||||
enqueue: (agentId: string, event: AgentStreamReducerEvent) => void;
|
|
||||||
flush: () => void;
|
|
||||||
flushAgent: (agentId: string) => void;
|
|
||||||
dispose: (options?: { flush?: boolean }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateAgentStreamReducerQueueInput = {
|
|
||||||
getSnapshot: (agentId: string) => AgentStreamReducerSnapshot;
|
|
||||||
commit: (
|
|
||||||
agentId: string,
|
|
||||||
result: ProcessAgentStreamEventOutput,
|
|
||||||
events: AgentStreamReducerEvent[],
|
|
||||||
) => void;
|
|
||||||
handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void;
|
|
||||||
scheduleFlush: (callback: () => void) => number;
|
|
||||||
cancelFlush: (id: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyAgentPatch(
|
|
||||||
currentAgent: AgentStreamReducerAgentSnapshot | null,
|
|
||||||
patch: AgentPatch | null,
|
|
||||||
): AgentStreamReducerAgentSnapshot | null {
|
|
||||||
if (!currentAgent || !patch) {
|
|
||||||
return currentAgent;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: patch.status,
|
|
||||||
updatedAt: patch.updatedAt,
|
|
||||||
lastActivityAt: patch.lastActivityAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function processAgentStreamEvent(
|
export function processAgentStreamEvent(
|
||||||
input: ProcessAgentStreamEventInput,
|
input: ProcessAgentStreamEventInput,
|
||||||
): ProcessAgentStreamEventOutput {
|
): ProcessAgentStreamEventOutput {
|
||||||
@@ -499,255 +430,3 @@ export function processAgentStreamEvent(
|
|||||||
sideEffects,
|
sideEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processAgentStreamEvents(
|
|
||||||
input: ProcessAgentStreamEventsInput,
|
|
||||||
): ProcessAgentStreamEventOutput {
|
|
||||||
let tail = input.currentTail;
|
|
||||||
let head = input.currentHead;
|
|
||||||
let cursor = input.currentCursor;
|
|
||||||
let agent = input.currentAgent;
|
|
||||||
let changedTail = false;
|
|
||||||
let changedHead = false;
|
|
||||||
let cursorChanged = false;
|
|
||||||
let agentPatch: AgentPatch | null = null;
|
|
||||||
let agentChanged = false;
|
|
||||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
|
||||||
|
|
||||||
for (const reducerEvent of input.events) {
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
event: reducerEvent.event,
|
|
||||||
seq: reducerEvent.seq,
|
|
||||||
epoch: reducerEvent.epoch,
|
|
||||||
currentTail: tail,
|
|
||||||
currentHead: head,
|
|
||||||
currentCursor: cursor,
|
|
||||||
currentAgent: agent,
|
|
||||||
timestamp: reducerEvent.timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
tail = result.tail;
|
|
||||||
head = result.head;
|
|
||||||
changedTail = changedTail || result.changedTail;
|
|
||||||
changedHead = changedHead || result.changedHead;
|
|
||||||
sideEffects.push(...result.sideEffects);
|
|
||||||
|
|
||||||
if (result.cursorChanged) {
|
|
||||||
cursor = result.cursor ?? undefined;
|
|
||||||
cursorChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.agentChanged) {
|
|
||||||
agentPatch = result.agent;
|
|
||||||
agentChanged = true;
|
|
||||||
agent = applyAgentPatch(agent, result.agent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tail,
|
|
||||||
head,
|
|
||||||
changedTail,
|
|
||||||
changedHead,
|
|
||||||
cursor: cursor ?? null,
|
|
||||||
cursorChanged,
|
|
||||||
agent: agentPatch,
|
|
||||||
agentChanged,
|
|
||||||
sideEffects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAgentStreamReducerQueue(
|
|
||||||
input: CreateAgentStreamReducerQueueInput,
|
|
||||||
): AgentStreamReducerQueue {
|
|
||||||
const pendingByAgentId = new Map<string, AgentStreamReducerEvent[]>();
|
|
||||||
let scheduledFlushId: number | null = null;
|
|
||||||
|
|
||||||
const cancelScheduledFlush = () => {
|
|
||||||
if (scheduledFlushId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.cancelFlush(scheduledFlushId);
|
|
||||||
scheduledFlushId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const flushAgent = (agentId: string) => {
|
|
||||||
const events = pendingByAgentId.get(agentId);
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingByAgentId.delete(agentId);
|
|
||||||
if (pendingByAgentId.size === 0) {
|
|
||||||
cancelScheduledFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = processAgentStreamEvents({
|
|
||||||
events,
|
|
||||||
...input.getSnapshot(agentId),
|
|
||||||
});
|
|
||||||
|
|
||||||
input.commit(agentId, result, events);
|
|
||||||
if (result.sideEffects.length > 0) {
|
|
||||||
input.handleSideEffects(agentId, result.sideEffects);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
const agentIds = Array.from(pendingByAgentId.keys());
|
|
||||||
for (const agentId of agentIds) {
|
|
||||||
flushAgent(agentId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleFlush = () => {
|
|
||||||
if (scheduledFlushId !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scheduledFlushId = input.scheduleFlush(() => {
|
|
||||||
scheduledFlushId = null;
|
|
||||||
flush();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
enqueue(agentId, event) {
|
|
||||||
const pending = pendingByAgentId.get(agentId);
|
|
||||||
if (pending) {
|
|
||||||
pending.push(event);
|
|
||||||
} else {
|
|
||||||
pendingByAgentId.set(agentId, [event]);
|
|
||||||
}
|
|
||||||
scheduleFlush();
|
|
||||||
},
|
|
||||||
flush,
|
|
||||||
flushAgent,
|
|
||||||
dispose(options) {
|
|
||||||
cancelScheduledFlush();
|
|
||||||
if (options?.flush) {
|
|
||||||
flush();
|
|
||||||
} else {
|
|
||||||
pendingByAgentId.clear();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamStatePatch = {
|
|
||||||
tail?: StreamItem[];
|
|
||||||
head?: StreamItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateSessionAgentStreamReducerQueueInput = {
|
|
||||||
serverId: string;
|
|
||||||
setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void;
|
|
||||||
setAgentTimelineCursor: (
|
|
||||||
serverId: string,
|
|
||||||
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
|
||||||
) => void;
|
|
||||||
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
|
|
||||||
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function scheduleAgentStreamReducerFlush(callback: () => void): number {
|
|
||||||
return setTimeout(callback, AGENT_STREAM_REDUCER_FLUSH_DELAY_MS) as unknown as number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelAgentStreamReducerFlush(id: number) {
|
|
||||||
clearTimeout(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSessionAgentStreamReducerQueue(
|
|
||||||
input: CreateSessionAgentStreamReducerQueueInput,
|
|
||||||
): AgentStreamReducerQueue {
|
|
||||||
const {
|
|
||||||
serverId,
|
|
||||||
setAgentStreamState,
|
|
||||||
setAgentTimelineCursor,
|
|
||||||
setAgents,
|
|
||||||
requestCanonicalCatchUp,
|
|
||||||
} = input;
|
|
||||||
|
|
||||||
return createAgentStreamReducerQueue({
|
|
||||||
getSnapshot: (agentId) => {
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
|
||||||
const currentAgentEntry = session?.agents.get(agentId);
|
|
||||||
return {
|
|
||||||
currentTail: session?.agentStreamTail.get(agentId) ?? [],
|
|
||||||
currentHead: session?.agentStreamHead.get(agentId) ?? [],
|
|
||||||
currentCursor: session?.agentTimelineCursor.get(agentId),
|
|
||||||
currentAgent: currentAgentEntry
|
|
||||||
? {
|
|
||||||
status: currentAgentEntry.status,
|
|
||||||
updatedAt: currentAgentEntry.updatedAt,
|
|
||||||
lastActivityAt: currentAgentEntry.lastActivityAt,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
commit: (agentId, result, events) => {
|
|
||||||
if (result.changedTail || result.changedHead) {
|
|
||||||
setAgentStreamState(serverId, agentId, {
|
|
||||||
...(result.changedTail ? { tail: result.tail } : {}),
|
|
||||||
...(result.changedHead ? { head: result.head } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.cursorChanged && result.cursor) {
|
|
||||||
const nextCursor = result.cursor;
|
|
||||||
const lastEvent = events.at(-1);
|
|
||||||
setAgentTimelineCursor(serverId, (prev) => {
|
|
||||||
const current = prev.get(agentId);
|
|
||||||
if (
|
|
||||||
current &&
|
|
||||||
lastEvent &&
|
|
||||||
typeof lastEvent.seq === "number" &&
|
|
||||||
typeof lastEvent.epoch === "string" &&
|
|
||||||
current.epoch === lastEvent.epoch &&
|
|
||||||
lastEvent.seq >= current.startSeq &&
|
|
||||||
lastEvent.seq <= current.endSeq
|
|
||||||
) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
current &&
|
|
||||||
current.epoch === nextCursor.epoch &&
|
|
||||||
current.startSeq === nextCursor.startSeq &&
|
|
||||||
current.endSeq === nextCursor.endSeq
|
|
||||||
) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, nextCursor);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.agentChanged && result.agent) {
|
|
||||||
const nextAgent = result.agent;
|
|
||||||
setAgents(serverId, (prev) => {
|
|
||||||
const current = prev.get(agentId);
|
|
||||||
if (!current) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, {
|
|
||||||
...current,
|
|
||||||
status: nextAgent.status,
|
|
||||||
updatedAt: nextAgent.updatedAt,
|
|
||||||
lastActivityAt: nextAgent.lastActivityAt,
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleSideEffects: (agentId, sideEffects) => {
|
|
||||||
for (const effect of sideEffects) {
|
|
||||||
if (effect.type === "catch_up") {
|
|
||||||
requestCanonicalCatchUp(agentId, effect.cursor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scheduleFlush: scheduleAgentStreamReducerFlush,
|
|
||||||
cancelFlush: cancelAgentStreamReducerFlush,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act, useEffect } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { xs: 11, sm: 13 },
|
|
||||||
fontWeight: { medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#f44",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const asyncStorage = vi.hoisted(() => ({
|
|
||||||
values: new Map<string, string>(),
|
|
||||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
|
||||||
setItem: vi.fn(async (key: string, value: string) => {
|
|
||||||
asyncStorage.values.set(key, value);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: asyncStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
|
||||||
return { X };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import {
|
|
||||||
SidebarCalloutProvider,
|
|
||||||
type SidebarCalloutsApi,
|
|
||||||
SidebarCalloutViewport,
|
|
||||||
useSidebarCallouts,
|
|
||||||
} from "./sidebar-callout-context";
|
|
||||||
|
|
||||||
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
|
|
||||||
const api = useSidebarCallouts();
|
|
||||||
onApi(api);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SidebarCalloutProvider", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let api: SidebarCalloutsApi | null = null;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
api = null;
|
|
||||||
asyncStorage.values.clear();
|
|
||||||
asyncStorage.getItem.mockClear();
|
|
||||||
asyncStorage.setItem.mockClear();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
|
||||||
<SidebarCalloutViewport />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
api = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
|
|
||||||
api?.show({ id: "update", priority: 200, title: "Update available" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
expect(container?.textContent).not.toContain("Set up scripts");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.dismiss("update");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Set up scripts");
|
|
||||||
expect(container?.textContent).not.toContain("Update available");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a callout by id without duplicating the queue item", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
|
|
||||||
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New daemon");
|
|
||||||
expect(container?.textContent).toContain("v2");
|
|
||||||
expect(container?.textContent).not.toContain("Old daemon");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps API consumers from rerendering when callout state changes", () => {
|
|
||||||
const renders = vi.fn();
|
|
||||||
function Producer() {
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
renders(callouts);
|
|
||||||
useEffect(() => {
|
|
||||||
callouts.show({ id: "initial", title: "Initial" });
|
|
||||||
}, [callouts]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<Producer />
|
|
||||||
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
|
||||||
<SidebarCalloutViewport />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const firstApi = renders.mock.calls[0]?.[0];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "later", priority: 10, title: "Later" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(renders).toHaveBeenCalledTimes(1);
|
|
||||||
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unregisters only the registration returned by show", () => {
|
|
||||||
let unregisterOld: (() => void) | null = null;
|
|
||||||
act(() => {
|
|
||||||
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
|
|
||||||
api?.show({ id: "update", title: "New" });
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
unregisterOld?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists dismissals by dismissal key", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.3",
|
|
||||||
title: "Update available",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.dismiss("update");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).not.toContain("Update available");
|
|
||||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
|
||||||
"@paseo:sidebar-callout-dismissals",
|
|
||||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.3",
|
|
||||||
title: "Dismissed update",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).not.toContain("Dismissed update");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.4",
|
|
||||||
title: "New update",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New update");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
type CalloutAction,
|
|
||||||
CalloutCard,
|
|
||||||
type CalloutCardProps,
|
|
||||||
type CalloutVariant,
|
|
||||||
} from "@/components/callout-card";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
|
|
||||||
export type SidebarCalloutAction = CalloutAction;
|
|
||||||
|
|
||||||
export interface SidebarCalloutOptions {
|
|
||||||
id: string;
|
|
||||||
dismissalKey?: string;
|
|
||||||
title: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
variant?: CalloutVariant;
|
|
||||||
actions?: readonly SidebarCalloutAction[];
|
|
||||||
dismissible?: boolean;
|
|
||||||
priority?: number;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarCalloutsApi {
|
|
||||||
show: (callout: SidebarCalloutOptions) => () => void;
|
|
||||||
dismiss: (id: string) => void;
|
|
||||||
clear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SidebarCalloutEntry = SidebarCalloutOptions & {
|
|
||||||
order: number;
|
|
||||||
priority: number;
|
|
||||||
token: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
|
||||||
|
|
||||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
|
||||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
|
||||||
|
|
||||||
function normalizeDismissalKey(key: string | null | undefined): string | null {
|
|
||||||
const trimmed = key?.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
|
||||||
if (!value) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value) as unknown;
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
|
||||||
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
|
|
||||||
(error) => {
|
|
||||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectActiveCallout(input: {
|
|
||||||
callouts: readonly SidebarCalloutEntry[];
|
|
||||||
dismissedKeys: ReadonlySet<string>;
|
|
||||||
dismissalStorageLoaded: boolean;
|
|
||||||
}): SidebarCalloutEntry | null {
|
|
||||||
const visibleCallouts = input.callouts.filter((entry) => {
|
|
||||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
|
||||||
if (!dismissalKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (visibleCallouts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
|
|
||||||
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
|
||||||
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
|
||||||
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
|
||||||
const dismissedKeysRef = useRef<Set<string>>(new Set());
|
|
||||||
const orderRef = useRef(0);
|
|
||||||
const tokenRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
|
|
||||||
.then((value) => {
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextKeys = parseDismissedCalloutKeys(value);
|
|
||||||
dismissedKeysRef.current = nextKeys;
|
|
||||||
setDismissedKeys(nextKeys);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (mounted) {
|
|
||||||
setDismissalStorageLoaded(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
|
||||||
tokenRef.current += 1;
|
|
||||||
const token = tokenRef.current;
|
|
||||||
const current = calloutsRef.current;
|
|
||||||
const existing = current.find((entry) => entry.id === callout.id);
|
|
||||||
const nextEntry: SidebarCalloutEntry = {
|
|
||||||
...callout,
|
|
||||||
priority: callout.priority ?? 0,
|
|
||||||
order: existing?.order ?? ++orderRef.current,
|
|
||||||
token,
|
|
||||||
};
|
|
||||||
const next = existing
|
|
||||||
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
|
||||||
: [...current, nextEntry];
|
|
||||||
|
|
||||||
calloutsRef.current = next;
|
|
||||||
setCallouts(next);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const updated = calloutsRef.current.filter(
|
|
||||||
(entry) => entry.id !== callout.id || entry.token !== token,
|
|
||||||
);
|
|
||||||
calloutsRef.current = updated;
|
|
||||||
setCallouts(updated);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dismiss = useStableEvent((id: string) => {
|
|
||||||
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
|
|
||||||
const next = calloutsRef.current.filter((entry) => entry.id !== id);
|
|
||||||
calloutsRef.current = next;
|
|
||||||
setCallouts(next);
|
|
||||||
|
|
||||||
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
|
|
||||||
if (dismissalKey) {
|
|
||||||
const nextKeys = new Set(dismissedKeysRef.current);
|
|
||||||
nextKeys.add(dismissalKey);
|
|
||||||
dismissedKeysRef.current = nextKeys;
|
|
||||||
setDismissedKeys(nextKeys);
|
|
||||||
persistDismissedCalloutKeys(nextKeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissed?.onDismiss?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
const clear = useStableEvent(() => {
|
|
||||||
calloutsRef.current = [];
|
|
||||||
setCallouts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
|
||||||
const activeCallout = useMemo(
|
|
||||||
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
|
||||||
[callouts, dismissedKeys, dismissalStorageLoaded],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarCalloutApiContext.Provider value={api}>
|
|
||||||
<SidebarCalloutStateContext.Provider value={activeCallout}>
|
|
||||||
{children}
|
|
||||||
</SidebarCalloutStateContext.Provider>
|
|
||||||
</SidebarCalloutApiContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSidebarCallouts(): SidebarCalloutsApi {
|
|
||||||
const api = useContext(SidebarCalloutApiContext);
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("useSidebarCallouts must be used within SidebarCalloutProvider");
|
|
||||||
}
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
|
|
||||||
return useContext(SidebarCalloutStateContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarCalloutViewport() {
|
|
||||||
const activeCallout = useActiveSidebarCallout();
|
|
||||||
const api = useSidebarCallouts();
|
|
||||||
if (!activeCallout) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardProps: CalloutCardProps = {
|
|
||||||
title: activeCallout.title,
|
|
||||||
description: activeCallout.description,
|
|
||||||
icon: activeCallout.icon,
|
|
||||||
variant: activeCallout.variant,
|
|
||||||
actions: activeCallout.actions,
|
|
||||||
onDismiss:
|
|
||||||
activeCallout.dismissible === false ? undefined : () => api.dismiss(activeCallout.id),
|
|
||||||
testID: activeCallout.testID,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <CalloutCard {...cardProps} />;
|
|
||||||
}
|
|
||||||
@@ -86,33 +86,6 @@ describe("desktop attachment store", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves raw base64 sources via desktop filesystem writes", async () => {
|
|
||||||
const store = createDesktopAttachmentStore();
|
|
||||||
const attachment = await store.save({
|
|
||||||
id: "att_base64",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "inline.png",
|
|
||||||
source: {
|
|
||||||
kind: "base64",
|
|
||||||
base64: "AAECAw==",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
|
|
||||||
attachmentId: "att_base64",
|
|
||||||
base64: "AAECAw==",
|
|
||||||
extension: ".png",
|
|
||||||
});
|
|
||||||
expect(attachment).toMatchObject({
|
|
||||||
id: "att_base64",
|
|
||||||
mimeType: "image/png",
|
|
||||||
storageType: "desktop-file",
|
|
||||||
storageKey: "/managed/att_2.png",
|
|
||||||
fileName: "inline.png",
|
|
||||||
byteSize: 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates encode/preview/delete/gc to desktop command path", async () => {
|
it("delegates encode/preview/delete/gc to desktop command path", async () => {
|
||||||
const store = createDesktopAttachmentStore();
|
const store = createDesktopAttachmentStore();
|
||||||
const attachment = {
|
const attachment = {
|
||||||
|
|||||||
@@ -166,16 +166,6 @@ export function createDesktopAttachmentStore(): AttachmentStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.source.kind === "base64") {
|
|
||||||
const mimeType = normalizeMimeType(input.mimeType);
|
|
||||||
return await saveDesktopAttachmentFromBase64({
|
|
||||||
id,
|
|
||||||
base64: input.source.base64,
|
|
||||||
mimeType,
|
|
||||||
fileName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
|
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
|
||||||
const base64 = await blobToBase64(input.source.blob);
|
const base64 = await blobToBase64(input.source.blob);
|
||||||
return await saveDesktopAttachmentFromBase64({
|
return await saveDesktopAttachmentFromBase64({
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||||
|
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const {
|
||||||
|
isDesktopApp,
|
||||||
|
status,
|
||||||
|
availableUpdate,
|
||||||
|
errorMessage,
|
||||||
|
checkForUpdates,
|
||||||
|
installUpdate,
|
||||||
|
isInstalling,
|
||||||
|
} = useDesktopAppUpdater();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktopApp) return;
|
||||||
|
|
||||||
|
void checkForUpdates({ silent: true });
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
void checkForUpdates({ silent: true });
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isDesktopApp, checkForUpdates]);
|
||||||
|
|
||||||
|
if (!isDesktopApp) return null;
|
||||||
|
if (dismissed) return null;
|
||||||
|
if (
|
||||||
|
status !== "available" &&
|
||||||
|
status !== "installed" &&
|
||||||
|
status !== "installing" &&
|
||||||
|
status !== "error"
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const isInstalled = status === "installed";
|
||||||
|
const isError = status === "error";
|
||||||
|
|
||||||
|
function getTitle(): string {
|
||||||
|
if (isInstalled) return "Update installed";
|
||||||
|
if (isInstalling) return "Installing update";
|
||||||
|
if (isError) return "Update failed";
|
||||||
|
return "Update available";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitle(): string {
|
||||||
|
if (isInstalled) return "Restart to use the new version.";
|
||||||
|
if (isInstalling) return "Installing and restarting...";
|
||||||
|
if (isError) return errorMessage ?? "Something went wrong.";
|
||||||
|
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
|
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
|
||||||
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<View style={styles.textSection}>
|
||||||
|
<Text style={styles.title}>{getTitle()}</Text>
|
||||||
|
<Text style={styles.subtitle}>{getSubtitle()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void openExternalUrl(CHANGELOG_URL)}
|
||||||
|
style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.outlineButtonText}>What's new</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{!isInstalled && !isError && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void installUpdate()}
|
||||||
|
disabled={isInstalling}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.primaryButton,
|
||||||
|
pressed && styles.buttonPressed,
|
||||||
|
isInstalling && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>
|
||||||
|
{isInstalling ? "Installing..." : "Install & restart"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void checkForUpdates()}
|
||||||
|
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>Retry</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: theme.spacing[4],
|
||||||
|
right: theme.spacing[4],
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingLeft: theme.spacing[4],
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
...theme.shadow.md,
|
||||||
|
maxWidth: 480,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -8,
|
||||||
|
left: -8,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
textSection: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
outlineButton: {
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
outlineButtonText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: theme.colors.surface0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
buttonPressed: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot";
|
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
iconSize: { sm: 14, md: 18 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { xs: 11, sm: 13 },
|
|
||||||
fontWeight: { medium: "500", semibold: "600" },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#ff4444",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const asyncStorage = vi.hoisted(() => ({
|
|
||||||
values: new Map<string, string>(),
|
|
||||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
|
||||||
setItem: vi.fn(async (key: string, value: string) => {
|
|
||||||
asyncStorage.values.set(key, value);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: asyncStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
|
||||||
const Gift = (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": "Gift" });
|
|
||||||
return { Gift, X };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updaterState = vi.hoisted(() => ({
|
|
||||||
value: {
|
|
||||||
isDesktopApp: true,
|
|
||||||
status: "available" as
|
|
||||||
| "idle"
|
|
||||||
| "checking"
|
|
||||||
| "pending"
|
|
||||||
| "up-to-date"
|
|
||||||
| "available"
|
|
||||||
| "installing"
|
|
||||||
| "installed"
|
|
||||||
| "error",
|
|
||||||
availableUpdate: {
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: true,
|
|
||||||
currentVersion: "1.2.2",
|
|
||||||
latestVersion: "1.2.3",
|
|
||||||
body: null,
|
|
||||||
date: null,
|
|
||||||
} as unknown,
|
|
||||||
errorMessage: null as string | null,
|
|
||||||
lastCheckedAt: null as number | null,
|
|
||||||
isChecking: false,
|
|
||||||
isInstalling: false,
|
|
||||||
statusText: "",
|
|
||||||
checkForUpdates: vi.fn(async () => null),
|
|
||||||
installUpdate: vi.fn(async () => null),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({
|
|
||||||
useDesktopAppUpdater: () => updaterState.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import { UpdateCalloutSource } from "./update-callout-source";
|
|
||||||
|
|
||||||
function resetUpdaterState(): void {
|
|
||||||
updaterState.value = {
|
|
||||||
isDesktopApp: true,
|
|
||||||
status: "available",
|
|
||||||
availableUpdate: {
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: true,
|
|
||||||
currentVersion: "1.2.2",
|
|
||||||
latestVersion: "1.2.3",
|
|
||||||
body: null,
|
|
||||||
date: null,
|
|
||||||
},
|
|
||||||
errorMessage: null,
|
|
||||||
lastCheckedAt: null,
|
|
||||||
isChecking: false,
|
|
||||||
isInstalling: false,
|
|
||||||
statusText: "",
|
|
||||||
checkForUpdates: vi.fn(async () => null),
|
|
||||||
installUpdate: vi.fn(async () => null),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) {
|
|
||||||
return (
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<UpdateCalloutSource />
|
|
||||||
{withSlot ? <SidebarCalloutSlot /> : null}
|
|
||||||
</SidebarCalloutProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderHarness(root: Root, withSlot = true): Promise<void> {
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<UpdateCalloutHarness withSlot={withSlot} />);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("UpdateCalloutSource", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetUpdaterState();
|
|
||||||
asyncStorage.values.clear();
|
|
||||||
asyncStorage.getItem.mockClear();
|
|
||||||
asyncStorage.setItem.mockClear();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers an update-available sidebar callout with changelog + install actions", async () => {
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull();
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
expect(container?.textContent).toContain("1.2.3");
|
|
||||||
expect(container?.textContent).toContain(
|
|
||||||
"Upgrading the app will stop running agents and close terminal sessions.",
|
|
||||||
);
|
|
||||||
expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
|
||||||
).toContain("What's new");
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
|
||||||
).toContain("Install & restart");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders only through the sidebar slot", async () => {
|
|
||||||
await renderHarness(root!, false);
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows only the changelog action once the update is installed", async () => {
|
|
||||||
updaterState.value = {
|
|
||||||
...updaterState.value,
|
|
||||||
status: "installed",
|
|
||||||
availableUpdate: null,
|
|
||||||
};
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update installed");
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
|
||||||
).toContain("What's new");
|
|
||||||
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables the install action and shows Installing... while installing", async () => {
|
|
||||||
updaterState.value = {
|
|
||||||
...updaterState.value,
|
|
||||||
status: "installing",
|
|
||||||
isInstalling: true,
|
|
||||||
};
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
|
||||||
).toContain("Installing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a retry action on error and surfaces the error message", async () => {
|
|
||||||
updaterState.value = {
|
|
||||||
...updaterState.value,
|
|
||||||
status: "error",
|
|
||||||
errorMessage: "Download failed",
|
|
||||||
availableUpdate: null,
|
|
||||||
};
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update failed");
|
|
||||||
expect(container?.textContent).toContain("Download failed");
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
|
||||||
).toContain("Retry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing when not running as a desktop app", async () => {
|
|
||||||
updaterState.value = { ...updaterState.value, isDesktopApp: false };
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing when status is idle / checking / up-to-date / pending", async () => {
|
|
||||||
for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
|
|
||||||
resetUpdaterState();
|
|
||||||
updaterState.value = { ...updaterState.value, status };
|
|
||||||
const innerContainer = document.createElement("div");
|
|
||||||
document.body.appendChild(innerContainer);
|
|
||||||
const innerRoot = createRoot(innerContainer);
|
|
||||||
await act(async () => {
|
|
||||||
innerRoot.render(<UpdateCalloutHarness />);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull();
|
|
||||||
act(() => {
|
|
||||||
innerRoot.unmount();
|
|
||||||
});
|
|
||||||
innerContainer.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismisses the registered update callout", async () => {
|
|
||||||
await renderHarness(root!);
|
|
||||||
|
|
||||||
const dismiss = container?.querySelector(
|
|
||||||
'[data-testid="update-callout-dismiss"]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(dismiss).not.toBeNull();
|
|
||||||
act(() => {
|
|
||||||
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
|
||||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
|
||||||
"@paseo:sidebar-callout-dismissals",
|
|
||||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { Gift } from "lucide-react-native";
|
|
||||||
import { type ReactNode, useEffect, useRef } from "react";
|
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
|
||||||
import { CalloutDescriptionText } from "@/components/callout-card";
|
|
||||||
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
|
||||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
|
||||||
|
|
||||||
export function UpdateCalloutSource() {
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const {
|
|
||||||
isDesktopApp,
|
|
||||||
status,
|
|
||||||
availableUpdate,
|
|
||||||
errorMessage,
|
|
||||||
checkForUpdates,
|
|
||||||
installUpdate,
|
|
||||||
isInstalling,
|
|
||||||
} = useDesktopAppUpdater();
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const openChangelog = useStableEvent(() => {
|
|
||||||
void openExternalUrl(CHANGELOG_URL);
|
|
||||||
});
|
|
||||||
const install = useStableEvent(() => {
|
|
||||||
void installUpdate();
|
|
||||||
});
|
|
||||||
const retry = useStableEvent(() => {
|
|
||||||
void checkForUpdates();
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDesktopApp) return;
|
|
||||||
|
|
||||||
void checkForUpdates({ silent: true });
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
void checkForUpdates({ silent: true });
|
|
||||||
}, CHECK_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isDesktopApp, checkForUpdates]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDesktopApp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
status !== "available" &&
|
|
||||||
status !== "installed" &&
|
|
||||||
status !== "installing" &&
|
|
||||||
status !== "error"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInstalled = status === "installed";
|
|
||||||
const isError = status === "error";
|
|
||||||
const isAvailable = !isInstalled && !isInstalling && !isError;
|
|
||||||
|
|
||||||
let title = "Update available";
|
|
||||||
if (isInstalled) {
|
|
||||||
title = "Update installed";
|
|
||||||
} else if (isInstalling) {
|
|
||||||
title = "Installing update";
|
|
||||||
} else if (isError) {
|
|
||||||
title = "Update failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
let description: ReactNode = <UpdateAvailableDescription />;
|
|
||||||
if (isInstalled) {
|
|
||||||
description = "Restart to use the new version.";
|
|
||||||
} else if (isInstalling) {
|
|
||||||
description = "Installing and restarting...";
|
|
||||||
} else if (isError) {
|
|
||||||
description = errorMessage ?? "Something went wrong.";
|
|
||||||
} else if (availableUpdate?.latestVersion) {
|
|
||||||
description = (
|
|
||||||
<UpdateAvailableDescription
|
|
||||||
versionLabel={`v${availableUpdate.latestVersion.replace(/^v/i, "")}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: openChangelog }];
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
actions.push({ label: "Retry", onPress: retry, variant: "primary" });
|
|
||||||
} else if (!isInstalled) {
|
|
||||||
actions.push({
|
|
||||||
label: isInstalling ? "Installing..." : "Install & restart",
|
|
||||||
onPress: install,
|
|
||||||
variant: "primary",
|
|
||||||
disabled: isInstalling,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return callouts.show({
|
|
||||||
id: "desktop-update",
|
|
||||||
dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`,
|
|
||||||
priority: 200,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon: isAvailable ? (
|
|
||||||
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
) : undefined,
|
|
||||||
variant: isError ? "error" : "default",
|
|
||||||
actions,
|
|
||||||
testID: "update-callout",
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
availableUpdate?.latestVersion,
|
|
||||||
callouts,
|
|
||||||
errorMessage,
|
|
||||||
install,
|
|
||||||
isDesktopApp,
|
|
||||||
isInstalling,
|
|
||||||
openChangelog,
|
|
||||||
retry,
|
|
||||||
status,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
theme.iconSize.sm,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CalloutDescriptionText>
|
|
||||||
{versionLabel
|
|
||||||
? `${versionLabel} is ready to install.`
|
|
||||||
: "A new version is ready to install."}
|
|
||||||
</CalloutDescriptionText>
|
|
||||||
<CalloutDescriptionText>
|
|
||||||
Upgrading the app will stop running agents and close terminal sessions.
|
|
||||||
</CalloutDescriptionText>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function agentHistoryQueryKey(serverId: string | null) {
|
|
||||||
return ["agentHistory", serverId] as const;
|
|
||||||
}
|
|
||||||
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
error: snapshotError,
|
error: snapshotError,
|
||||||
refresh: refreshSnapshot,
|
refresh: refreshSnapshot,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(formState.serverId);
|
} = useProvidersSnapshot(formState.serverId, formState.workingDir);
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
const snapshotProviderDefinitions = useMemo(
|
const snapshotProviderDefinitions = useMemo(
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type {
|
|
||||||
DaemonClient,
|
|
||||||
FetchAgentHistoryEntry,
|
|
||||||
FetchAgentHistoryOptions,
|
|
||||||
} from "@server/client/daemon-client";
|
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
|
||||||
import { useAgentHistory } from "./use-agent-history";
|
|
||||||
|
|
||||||
const { mockClient, mockRuntimeStore } = vi.hoisted(() => {
|
|
||||||
const mockClient = {
|
|
||||||
fetchAgentHistory: vi.fn(),
|
|
||||||
};
|
|
||||||
const mockRuntimeStore = {
|
|
||||||
refreshAgentDirectory: vi.fn(),
|
|
||||||
};
|
|
||||||
return { mockClient, mockRuntimeStore };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
getHostRuntimeStore: () => mockRuntimeStore,
|
|
||||||
useHostRuntimeClient: () => mockClient,
|
|
||||||
useHostRuntimeIsConnected: () => true,
|
|
||||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createQueryClient(): QueryClient {
|
|
||||||
return new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { retry: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAgentHistoryHook(options?: { enabled?: boolean }) {
|
|
||||||
const queryClient = createQueryClient();
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
||||||
|
|
||||||
return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), {
|
|
||||||
wrapper,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHistoryPayload(input: {
|
|
||||||
entries: FetchAgentHistoryEntry[];
|
|
||||||
hasMore?: boolean;
|
|
||||||
nextCursor?: string | null;
|
|
||||||
}): Awaited<ReturnType<DaemonClient["fetchAgentHistory"]>> {
|
|
||||||
return {
|
|
||||||
requestId: "req_history",
|
|
||||||
entries: input.entries,
|
|
||||||
pageInfo: {
|
|
||||||
nextCursor: input.nextCursor ?? null,
|
|
||||||
prevCursor: null,
|
|
||||||
hasMore: input.hasMore ?? false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHistoryEntry(input: {
|
|
||||||
id: string;
|
|
||||||
cwd: string;
|
|
||||||
updatedAt: string;
|
|
||||||
title?: string | null;
|
|
||||||
archivedAt?: string | null;
|
|
||||||
}): FetchAgentHistoryEntry {
|
|
||||||
return {
|
|
||||||
agent: {
|
|
||||||
id: input.id,
|
|
||||||
provider: "codex",
|
|
||||||
status: "closed",
|
|
||||||
createdAt: input.updatedAt,
|
|
||||||
updatedAt: input.updatedAt,
|
|
||||||
lastUserMessageAt: null,
|
|
||||||
lastError: undefined,
|
|
||||||
runtimeInfo: {
|
|
||||||
provider: "codex",
|
|
||||||
sessionId: null,
|
|
||||||
},
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: true,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
},
|
|
||||||
currentModeId: null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: null,
|
|
||||||
title: input.title ?? null,
|
|
||||||
cwd: input.cwd,
|
|
||||||
model: null,
|
|
||||||
thinkingOptionId: null,
|
|
||||||
requiresAttention: false,
|
|
||||||
attentionReason: null,
|
|
||||||
attentionTimestamp: null,
|
|
||||||
archivedAt: input.archivedAt ?? null,
|
|
||||||
labels: {},
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
projectKey: input.cwd,
|
|
||||||
projectName: "workspace",
|
|
||||||
checkout: {
|
|
||||||
cwd: input.cwd,
|
|
||||||
isGit: false,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
worktreeRoot: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeActiveAgent(): Agent {
|
|
||||||
const timestamp = new Date("2026-04-01T10:00:00.000Z");
|
|
||||||
return {
|
|
||||||
serverId: "server-1",
|
|
||||||
id: "active-1",
|
|
||||||
provider: "codex",
|
|
||||||
status: "idle",
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
lastUserMessageAt: null,
|
|
||||||
lastActivityAt: timestamp,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: true,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
},
|
|
||||||
currentModeId: null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: null,
|
|
||||||
title: "Active",
|
|
||||||
cwd: "/repo",
|
|
||||||
model: null,
|
|
||||||
labels: {},
|
|
||||||
archivedAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockClient.fetchAgentHistory.mockReset();
|
|
||||||
mockRuntimeStore.refreshAgentDirectory.mockReset();
|
|
||||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useAgentHistory", () => {
|
|
||||||
it("loads history one page at a time without refreshing active agents", async () => {
|
|
||||||
mockClient.fetchAgentHistory
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeHistoryPayload({
|
|
||||||
entries: [
|
|
||||||
makeHistoryEntry({
|
|
||||||
id: "history-1",
|
|
||||||
cwd: "/repo",
|
|
||||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
|
||||||
title: "History one",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
hasMore: true,
|
|
||||||
nextCursor: "cursor-2",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeHistoryPayload({
|
|
||||||
entries: [
|
|
||||||
makeHistoryEntry({
|
|
||||||
id: "history-2",
|
|
||||||
cwd: "/repo",
|
|
||||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
|
||||||
title: "History two",
|
|
||||||
archivedAt: "2026-04-01T10:05:00.000Z",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useSessionStore
|
|
||||||
.getState()
|
|
||||||
.initializeSession("server-1", mockClient as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]]));
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderAgentHistoryHook();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([
|
|
||||||
{
|
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
|
||||||
page: { limit: 200 },
|
|
||||||
},
|
|
||||||
] satisfies FetchAgentHistoryOptions[]);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1"]);
|
|
||||||
});
|
|
||||||
expect(result.current.hasMore).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
result.current.loadMore();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({
|
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
|
||||||
page: { limit: 200, cursor: "cursor-2" },
|
|
||||||
} satisfies FetchAgentHistoryOptions);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1", "history-2"]);
|
|
||||||
});
|
|
||||||
expect(result.current.hasMore).toBe(false);
|
|
||||||
expect(
|
|
||||||
Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []),
|
|
||||||
).toEqual(["active-1"]);
|
|
||||||
expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits until history is enabled before calling the history RPC", async () => {
|
|
||||||
mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] }));
|
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
||||||
let enabled = false;
|
|
||||||
const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), {
|
|
||||||
wrapper,
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
enabled = true;
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type {
|
|
||||||
DaemonClient,
|
|
||||||
FetchAgentHistoryOptions,
|
|
||||||
FetchAgentHistoryPageInfo,
|
|
||||||
} from "@server/client/daemon-client";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
|
||||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
|
||||||
|
|
||||||
const AGENT_HISTORY_PAGE_LIMIT = 200;
|
|
||||||
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
|
|
||||||
{ key: "updated_at", direction: "desc" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface AgentHistoryResult {
|
|
||||||
agents: AggregatedAgent[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isInitialLoad: boolean;
|
|
||||||
isRevalidating: boolean;
|
|
||||||
hasMore: boolean;
|
|
||||||
isLoadingMore: boolean;
|
|
||||||
refreshAll: () => void;
|
|
||||||
loadMore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentHistoryPage = {
|
|
||||||
agents: AggregatedAgent[];
|
|
||||||
pageInfo: FetchAgentHistoryPageInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchAgentHistoryPage(input: {
|
|
||||||
client: DaemonClient;
|
|
||||||
serverId: string;
|
|
||||||
cursor: string | null;
|
|
||||||
}): Promise<AgentHistoryPage> {
|
|
||||||
const payload = await input.client.fetchAgentHistory({
|
|
||||||
sort: AGENT_HISTORY_SORT,
|
|
||||||
page: input.cursor
|
|
||||||
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
|
|
||||||
: { limit: AGENT_HISTORY_PAGE_LIMIT },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { agents } = buildAgentDirectoryState({
|
|
||||||
serverId: input.serverId,
|
|
||||||
entries: payload.entries,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
agents: Array.from(agents.values(), (agent) => ({
|
|
||||||
id: agent.id,
|
|
||||||
serverId: input.serverId,
|
|
||||||
serverLabel: input.serverId,
|
|
||||||
title: agent.title ?? null,
|
|
||||||
status: agent.status,
|
|
||||||
lastActivityAt: agent.lastActivityAt,
|
|
||||||
cwd: agent.cwd,
|
|
||||||
provider: agent.provider,
|
|
||||||
pendingPermissionCount: agent.pendingPermissions.length,
|
|
||||||
requiresAttention: agent.requiresAttention,
|
|
||||||
attentionReason: agent.attentionReason,
|
|
||||||
attentionTimestamp: agent.attentionTimestamp ?? null,
|
|
||||||
archivedAt: agent.archivedAt ?? null,
|
|
||||||
createdAt: agent.createdAt,
|
|
||||||
labels: agent.labels,
|
|
||||||
})),
|
|
||||||
pageInfo: payload.pageInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentHistory(options: {
|
|
||||||
serverId?: string | null;
|
|
||||||
enabled?: boolean;
|
|
||||||
}): AgentHistoryResult {
|
|
||||||
const daemons = useHosts();
|
|
||||||
const serverId = useMemo(() => {
|
|
||||||
const value = options.serverId;
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
||||||
}, [options.serverId]);
|
|
||||||
const enabled = options.enabled ?? true;
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
|
||||||
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
|
|
||||||
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
|
||||||
|
|
||||||
const historyQuery = useInfiniteQuery<
|
|
||||||
AgentHistoryPage,
|
|
||||||
Error,
|
|
||||||
{ pages: AgentHistoryPage[] },
|
|
||||||
ReturnType<typeof agentHistoryQueryKey>,
|
|
||||||
string | null
|
|
||||||
>({
|
|
||||||
queryKey,
|
|
||||||
enabled: Boolean(enabled && serverId && client && isConnected),
|
|
||||||
staleTime: 30_000,
|
|
||||||
initialPageParam: null as string | null,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
|
|
||||||
queryFn: async ({ pageParam }) => {
|
|
||||||
if (!serverId || !client) {
|
|
||||||
throw new Error("Host is not connected");
|
|
||||||
}
|
|
||||||
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
|
|
||||||
historyQuery;
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
|
||||||
if (!serverId || !client || !isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void refetch();
|
|
||||||
}, [client, isConnected, refetch, serverId]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void fetchNextPage();
|
|
||||||
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
|
|
||||||
|
|
||||||
const agents = useMemo(
|
|
||||||
() =>
|
|
||||||
(data?.pages ?? [])
|
|
||||||
.flatMap((page) => page.agents)
|
|
||||||
.map((agent) => ({
|
|
||||||
...agent,
|
|
||||||
serverLabel: serverLabel ?? agent.serverLabel,
|
|
||||||
})),
|
|
||||||
[data?.pages, serverLabel],
|
|
||||||
);
|
|
||||||
const isInitialLoad = isLoading && agents.length === 0;
|
|
||||||
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
agents,
|
|
||||||
isLoading,
|
|
||||||
isInitialLoad,
|
|
||||||
isRevalidating,
|
|
||||||
hasMore: Boolean(hasNextPage),
|
|
||||||
isLoadingMore: isFetchingNextPage,
|
|
||||||
refreshAll,
|
|
||||||
loadMore,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const __private__ = {
|
|
||||||
fetchAgentHistoryPage,
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import { QueryClient } from "@tanstack/react-query";
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
|
||||||
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
|
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||||
@@ -113,17 +112,6 @@ describe("useArchiveAgent", () => {
|
|||||||
queryClient.setQueryData(["allAgents", "server-a"], {
|
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||||
});
|
});
|
||||||
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
agents: [
|
|
||||||
{ id: "agent-1", archivedAt: null },
|
|
||||||
{ id: "agent-2", archivedAt: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageParams: [null],
|
|
||||||
});
|
|
||||||
|
|
||||||
applyArchivedAgentCloseResults({
|
applyArchivedAgentCloseResults({
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -143,17 +131,6 @@ describe("useArchiveAgent", () => {
|
|||||||
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
|
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
|
||||||
entries: [{ agent: { id: "agent-2" } }],
|
entries: [{ agent: { id: "agent-2" } }],
|
||||||
});
|
});
|
||||||
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
agents: [
|
|
||||||
{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") },
|
|
||||||
{ id: "agent-2", archivedAt: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageParams: [null],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can apply archived agent close results without invalidating cached lists", () => {
|
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||||
@@ -166,14 +143,6 @@ describe("useArchiveAgent", () => {
|
|||||||
queryClient.setQueryData(["allAgents", "server-a"], {
|
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||||
});
|
});
|
||||||
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
agents: [{ id: "agent-1", archivedAt: null }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageParams: [null],
|
|
||||||
});
|
|
||||||
|
|
||||||
applyArchivedAgentCloseResults({
|
applyArchivedAgentCloseResults({
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -184,14 +153,5 @@ describe("useArchiveAgent", () => {
|
|||||||
|
|
||||||
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
|
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
|
||||||
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
|
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
|
||||||
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
|
|
||||||
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
agents: [{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageParams: [null],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
|
||||||
|
|
||||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ interface AgentsListQueryData {
|
|||||||
interface ArchivedAgentListCacheSnapshot {
|
interface ArchivedAgentListCacheSnapshot {
|
||||||
sidebarAgentsList: AgentsListQueryData | undefined;
|
sidebarAgentsList: AgentsListQueryData | undefined;
|
||||||
allAgents: AgentsListQueryData | undefined;
|
allAgents: AgentsListQueryData | undefined;
|
||||||
agentHistory: AgentHistoryQueryData | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchiveAgentMutationContext {
|
interface ArchiveAgentMutationContext {
|
||||||
@@ -41,19 +39,6 @@ interface ArchiveAgentMutationContext {
|
|||||||
lists: ArchivedAgentListCacheSnapshot;
|
lists: ArchivedAgentListCacheSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentHistoryQueryAgent {
|
|
||||||
id?: string | null;
|
|
||||||
archivedAt?: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentHistoryQueryPage {
|
|
||||||
agents?: AgentHistoryQueryAgent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentHistoryQueryData {
|
|
||||||
pages?: AgentHistoryQueryPage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArchiveKey(input: ArchiveAgentInput): string {
|
function toArchiveKey(input: ArchiveAgentInput): string {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
const agentId = input.agentId.trim();
|
const agentId = input.agentId.trim();
|
||||||
@@ -136,54 +121,6 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | undefined>(
|
|
||||||
payload: T,
|
|
||||||
input: ArchiveAgentInput & { archivedAt: string },
|
|
||||||
): T {
|
|
||||||
if (!payload || !Array.isArray(payload.pages) || !input.agentId) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archivedAt = new Date(input.archivedAt);
|
|
||||||
if (Number.isNaN(archivedAt.getTime())) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
const pages = payload.pages.map((page) => {
|
|
||||||
if (!Array.isArray(page.agents)) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageChanged = false;
|
|
||||||
const agents = page.agents.map((agent) => {
|
|
||||||
if (agent.id !== input.agentId) {
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
pageChanged = true;
|
|
||||||
changed = true;
|
|
||||||
return {
|
|
||||||
...agent,
|
|
||||||
archivedAt,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return pageChanged ? { ...page, agents } : page;
|
|
||||||
});
|
|
||||||
|
|
||||||
return changed ? ({ ...payload, pages } as T) : payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAgentArchivedInHistoryCache(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
input: ArchiveAgentInput & { archivedAt: string },
|
|
||||||
): void {
|
|
||||||
queryClient.setQueryData<AgentHistoryQueryData | undefined>(
|
|
||||||
agentHistoryQueryKey(input.serverId),
|
|
||||||
(current) => markAgentArchivedInHistoryPayload(current, input),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
||||||
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||||
}
|
}
|
||||||
@@ -224,16 +161,13 @@ function getArchivedAgentListCacheSnapshot(
|
|||||||
serverId,
|
serverId,
|
||||||
]),
|
]),
|
||||||
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
|
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
|
||||||
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
|
|
||||||
agentHistoryQueryKey(serverId),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCachedQuerySnapshot<T>(
|
function restoreCachedListSnapshot(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
queryKey: readonly unknown[],
|
queryKey: readonly [string, string],
|
||||||
snapshot: T | undefined,
|
snapshot: AgentsListQueryData | undefined,
|
||||||
): void {
|
): void {
|
||||||
if (snapshot === undefined) {
|
if (snapshot === undefined) {
|
||||||
queryClient.removeQueries({ queryKey, exact: true });
|
queryClient.removeQueries({ queryKey, exact: true });
|
||||||
@@ -247,13 +181,12 @@ function restoreArchivedAgentListCacheSnapshot(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
snapshot: ArchivedAgentListCacheSnapshot,
|
snapshot: ArchivedAgentListCacheSnapshot,
|
||||||
): void {
|
): void {
|
||||||
restoreCachedQuerySnapshot(
|
restoreCachedListSnapshot(
|
||||||
queryClient,
|
queryClient,
|
||||||
["sidebarAgentsList", serverId],
|
["sidebarAgentsList", serverId],
|
||||||
snapshot.sidebarAgentsList,
|
snapshot.sidebarAgentsList,
|
||||||
);
|
);
|
||||||
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||||
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
||||||
@@ -302,11 +235,6 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
|||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
agentId: result.agentId,
|
agentId: result.agentId,
|
||||||
});
|
});
|
||||||
markAgentArchivedInHistoryCache(input.queryClient, {
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: result.agentId,
|
|
||||||
archivedAt: result.archivedAt,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.invalidateQueries ?? true) {
|
if (input.invalidateQueries ?? true) {
|
||||||
@@ -316,9 +244,6 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
|||||||
void input.queryClient.invalidateQueries({
|
void input.queryClient.invalidateQueries({
|
||||||
queryKey: ["allAgents", input.serverId],
|
queryKey: ["allAgents", input.serverId],
|
||||||
});
|
});
|
||||||
void input.queryClient.invalidateQueries({
|
|
||||||
queryKey: agentHistoryQueryKey(input.serverId),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,9 +324,6 @@ export function useArchiveAgent() {
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["allAgents", input.serverId],
|
queryKey: ["allAgents", input.serverId],
|
||||||
});
|
});
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: agentHistoryQueryKey(input.serverId),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,5 +359,4 @@ export const __private__ = {
|
|||||||
setAgentArchiving,
|
setAgentArchiving,
|
||||||
isAgentArchiving,
|
isAgentArchiving,
|
||||||
removeAgentFromListPayload,
|
removeAgentFromListPayload,
|
||||||
markAgentArchivedInHistoryPayload,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,10 +49,7 @@ vi.mock("@server/client/daemon-client", () => ({}));
|
|||||||
function createTestClient() {
|
function createTestClient() {
|
||||||
return {
|
return {
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
subscribeConnectionStatus: vi.fn((listener: (state: { status: "connected" }) => void) => {
|
subscribeConnectionStatus: vi.fn(() => vi.fn()),
|
||||||
listener({ status: "connected" });
|
|
||||||
return vi.fn();
|
|
||||||
}),
|
|
||||||
sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(),
|
sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,15 +68,13 @@ function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
|
|||||||
|
|
||||||
async function renderActivityHook({
|
async function renderActivityHook({
|
||||||
client = createTestClient(),
|
client = createTestClient(),
|
||||||
focusedAgentId = "agent-1",
|
|
||||||
}: {
|
}: {
|
||||||
client?: ReturnType<typeof createTestClient>;
|
client?: ReturnType<typeof createTestClient>;
|
||||||
focusedAgentId?: string | null;
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
function Probe({ focusedAgentId }: { focusedAgentId: string | null }) {
|
function Probe() {
|
||||||
useClientActivity({
|
useClientActivity({
|
||||||
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
|
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
|
||||||
focusedAgentId,
|
focusedAgentId: "agent-1",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -89,16 +84,10 @@ async function renderActivityHook({
|
|||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<Probe focusedAgentId={focusedAgentId} />);
|
root.render(<Probe />);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rerender = async (nextFocusedAgentId: string | null) => {
|
return { client, root };
|
||||||
await act(async () => {
|
|
||||||
root.render(<Probe focusedAgentId={nextFocusedAgentId} />);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { client, rerender, root };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function advance(ms: number) {
|
async function advance(ms: number) {
|
||||||
@@ -149,24 +138,6 @@ describe("useClientActivity", () => {
|
|||||||
expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled();
|
expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends one immediate heartbeat when the focused agent changes", async () => {
|
|
||||||
const rendered = await renderActivityHook();
|
|
||||||
root = rendered.root;
|
|
||||||
|
|
||||||
expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1);
|
|
||||||
rendered.client.sendHeartbeat.mockClear();
|
|
||||||
|
|
||||||
vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z"));
|
|
||||||
await rendered.rerender("agent-2");
|
|
||||||
|
|
||||||
expect(rendered.client.subscribeConnectionStatus).toHaveBeenCalledTimes(1);
|
|
||||||
expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1);
|
|
||||||
expect(latestHeartbeat(rendered.client)).toMatchObject({
|
|
||||||
focusedAgentId: "agent-2",
|
|
||||||
lastActivityAt: "2026-04-19T10:00:05.000Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drives lastActivityAt forward from Electron idle polling", async () => {
|
it("drives lastActivityAt forward from Electron idle polling", async () => {
|
||||||
platformState.isElectron = true;
|
platformState.isElectron = true;
|
||||||
getDesktopSystemIdleTimeMs.mockResolvedValue(0);
|
getDesktopSystemIdleTimeMs.mockResolvedValue(0);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user