mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eab53e24e | ||
|
|
341a86aa52 | ||
|
|
1622da40d9 | ||
|
|
229f3cd5a8 | ||
|
|
1f5b6143d6 | ||
|
|
d779514f10 | ||
|
|
0859a67ab1 | ||
|
|
56fd50988c | ||
|
|
469d51289b | ||
|
|
a7a708bec9 | ||
|
|
c43df5d4c3 | ||
|
|
c46aee4e6a | ||
|
|
9c8d775895 | ||
|
|
8c89e72a06 | ||
|
|
a5d2b7d863 | ||
|
|
1c51b52d07 | ||
|
|
c424f82922 | ||
|
|
b403dea32b | ||
|
|
dd7671be20 | ||
|
|
38c22139bb | ||
|
|
c99b8ad5f4 | ||
|
|
e4cd2d1ad0 | ||
|
|
ae80ec4252 | ||
|
|
ab7d66dfed | ||
|
|
efd6023b90 | ||
|
|
c8a417c4fb | ||
|
|
78b285059f | ||
|
|
6f76a8b139 |
@@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this,
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable)
|
||||
- [ ] One focused change
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
|
||||
@@ -50,11 +50,26 @@ app:
|
||||
- "packages/expo-two-way-audio/**"
|
||||
|
||||
sdk:
|
||||
- "packages/plugin/**"
|
||||
- "plugin-examples/**"
|
||||
- "public-docs/plugins/v0.8/**"
|
||||
- "packages/client/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
|
||||
browser:
|
||||
- "packages/server/src/server/agent/provider-snapshot-manager.ts"
|
||||
- "packages/server/src/server/session/provider/provider-catalog-session.ts"
|
||||
- "packages/client/src/compat/normalize-provider-models.ts"
|
||||
- "packages/protocol/src/client-capabilities.ts"
|
||||
- "packages/server/src/server/agent/provider-registry.ts"
|
||||
- "packages/server/src/server/agent/agent-sdk-types.ts"
|
||||
- "packages/server/src/server/agent/providers/codex-app-server-agent.ts"
|
||||
- "packages/server/src/server/agent/providers/claude/agent.ts"
|
||||
- "packages/server/src/server/agent/plugin-provider.ts"
|
||||
- "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts"
|
||||
- "packages/server/src/executable-resolution/**"
|
||||
- "packages/plugin/src/server/provider.ts"
|
||||
- "packages/app/src/!(desktop)/**"
|
||||
- "packages/app/e2e/browser/**"
|
||||
- "packages/app/e2e/support/**"
|
||||
|
||||
@@ -298,6 +298,15 @@ jobs:
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Typecheck plugin examples
|
||||
run: npm run typecheck --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
|
||||
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
|
||||
|
||||
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
|
||||
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
|
||||
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
|
||||
### Added
|
||||
|
||||
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
|
||||
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
|
||||
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
|
||||
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
|
||||
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -108,6 +108,8 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
|
||||
@@ -38,6 +38,8 @@ Due to the volume and my limited bandwidth, I may not participate in all the dis
|
||||
|
||||
## Pull requests
|
||||
|
||||
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
|
||||
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
|
||||
@@ -47,6 +47,15 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
|
||||
|
||||
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
|
||||
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||
|
||||
@@ -21,6 +21,10 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
Reload releases the old runtime before resuming its durable session: an idle provider process can
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config.
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
|
||||
|
||||
+2
-2
@@ -574,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o
|
||||
|
||||
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
|
||||
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
|
||||
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
|
||||
and have at most one row per host.
|
||||
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
|
||||
uses the singleton id.
|
||||
|
||||
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
||||
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ that pane from the workspace split tree and docks it separately. Persisted ident
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
|
||||
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
|
||||
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
|
||||
focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
|
||||
+15
-1
@@ -64,7 +64,10 @@ root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
navigation. Both paths wait for the root navigation container to be ready. The
|
||||
workspace navigation owner keeps the latest pending intent across ref
|
||||
re-registration and applies it on the container's `ready` event; routes must not
|
||||
add their own readiness retries.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen. Follow the
|
||||
@@ -77,6 +80,9 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
distinct route names. `router.dismissTo()` ultimately matches stack entries by
|
||||
@@ -141,6 +147,14 @@ Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested
|
||||
stack owns its own screens. See [unistyles.md](unistyles.md).
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
@@ -78,6 +78,14 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
||||
timezone) once — never in a preview line AND a helper line.
|
||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
||||
|
||||
## Settings rows
|
||||
|
||||
Use the named components from `@/components/settings` for settings cards and controls.
|
||||
`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens
|
||||
can import `SettingsGroup` and `SettingsSection` directly from their modules in
|
||||
`@/components/settings/headings/`.
|
||||
The plugin UI entry exposes the same components; keep them independent of storage and routing.
|
||||
|
||||
## Data gating
|
||||
|
||||
Aggregate hooks return a discriminated load state:
|
||||
|
||||
+35
-56
@@ -25,69 +25,48 @@ during interactive login or later with `paseo hub permissions grant hub.execute`
|
||||
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||
manage their own relationship or permissions.
|
||||
|
||||
## Session grants and execution ownership
|
||||
## Session grants and agent operations
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||
connection receives its persisted permissions. Legacy relationship files carrying
|
||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
|
||||
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
|
||||
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
|
||||
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
|
||||
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
|
||||
response returns the existing agent and its current state; there is no separate reconciliation RPC.
|
||||
Transient stream frames are not durably replayed.
|
||||
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
|
||||
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
|
||||
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
|
||||
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
|
||||
Provider controls remain provider-native; see [providers.md](providers.md).
|
||||
|
||||
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
|
||||
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
|
||||
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit
|
||||
`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using
|
||||
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
|
||||
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
|
||||
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
|
||||
|
||||
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
|
||||
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
|
||||
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
|
||||
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
|
||||
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
|
||||
record so provider sessions can recover after a restart; neither ordinary client snapshots and
|
||||
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
|
||||
supported provider keys.
|
||||
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
hashes, never prompts, environment values, or credentials.
|
||||
|
||||
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
|
||||
Every reference must name an MCP server injected by the same create request. The daemon translates
|
||||
only those identities into the selected provider's native approval configuration. The protocol
|
||||
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
|
||||
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
|
||||
Hub creation instead of broadening access or waiting for an invisible prompt.
|
||||
When a create request includes a tool policy, a successful response includes
|
||||
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
|
||||
Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then
|
||||
`workspace.recovery.restore.request` and await success. The native message handler unarchives the
|
||||
agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's
|
||||
native steering behavior, including Paseo's existing behavior when that provider cannot steer.
|
||||
Execution completion and arrival-specific output authority remain Hub responsibilities.
|
||||
|
||||
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
|
||||
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
|
||||
and the provider's local or managed policy remain the authorization controls.
|
||||
|
||||
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
|
||||
the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
|
||||
actions are idempotent and continue to resolve from stored ownership after daemon restart.
|
||||
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
|
||||
required workspaceId and sends it through the shared workspace archive service. The service archives
|
||||
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
|
||||
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
|
||||
sharing a backing directory remain active.
|
||||
The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership
|
||||
and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ The UI worklet owns transient motion:
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility.
|
||||
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
|
||||
## Why one position
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
|
||||
|
||||
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
|
||||
|
||||
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
|
||||
with an empty prompt, hide its keyboard, and run:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs
|
||||
```
|
||||
|
||||
Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's
|
||||
focused input identity and IME hide/show events. It saves screenshots and logs under
|
||||
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
|
||||
to test an installed production build. It never submits a chat message.
|
||||
|
||||
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
|
||||
|
||||
## Maestro compatibility
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | The Hub-owned execution lifecycle |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+79
-22
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
|
||||
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
@@ -41,7 +41,7 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
|
||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
|
||||
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
|
||||
The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned
|
||||
directories, and local typechecking support. At least one entry is required.
|
||||
|
||||
```text
|
||||
@@ -62,10 +62,15 @@ runtime modules, so consumers do not install these packages when adding the plug
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
"id": "my-plugin",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
```
|
||||
|
||||
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
|
||||
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
|
||||
manifests and prerelease matching.
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
runtime does not compare the two IDs. The same directory can be installed under several config
|
||||
@@ -111,6 +116,7 @@ step:
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"requirements": { "paseo": ">=0.8.0" },
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
@@ -152,24 +158,46 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`.
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts |
|
||||
| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks |
|
||||
### SDK import boundaries
|
||||
|
||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
||||
Classify every SDK export before adding it. All client entry points and implementations live under
|
||||
`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types,
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary.
|
||||
React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any
|
||||
server entry. Node and platform-specific code must never be reachable from the shared root.
|
||||
|
||||
The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change
|
||||
must preserve them and update the public reference, migration guide, scaffold, and examples when
|
||||
an author-facing import changes. The plugin compiler enforces the same runtime entry rules for
|
||||
plugin-authored code. Keep the package export map and host-provided module maps consistent.
|
||||
|
||||
The compiler rejects imports across runtime directories or SDK entries, React dependencies in
|
||||
server code, and Node imports in client code (including bare names such as `fs`). Shared modules
|
||||
cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with
|
||||
empty module stubs. A relative
|
||||
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
|
||||
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
|
||||
Native calls such as `StyleSheet.create` belong in `client/`.
|
||||
@@ -183,7 +211,7 @@ pattern.
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greetRpc } from "./shared/greeting";
|
||||
|
||||
@@ -195,7 +223,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
@@ -219,7 +247,7 @@ typed async function. Use the host-provided `@tanstack/react-query` for request
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
||||
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
@@ -255,14 +283,25 @@ optional client-owned agent and workspace navigation; its absence is the compati
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
|
||||
of its sessions; plugin RPC is not part of the provider data path.
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
import { createProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
@@ -271,6 +310,12 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
|
||||
probes. The callback runs in the plugin process before discovery and receives the actual global or
|
||||
workspace target. Return a key covering effective configuration and execution environment, or
|
||||
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
|
||||
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
|
||||
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
@@ -282,7 +327,7 @@ persistence. Providers re-read credentials, environment, global configuration, a
|
||||
`session.open`; there is no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
@@ -414,7 +459,7 @@ credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { search } from "./server/issues";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
@@ -426,7 +471,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { issues } from "./shared/issues";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
@@ -440,6 +485,18 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
## Contribute settings
|
||||
|
||||
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
|
||||
The host settings shell owns navigation and layout; plugin content must not add another page
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
|
||||
and `plugin-examples/settings` for the named UI components and persistence API.
|
||||
|
||||
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
|
||||
Its writer lives with the plugin subprocess, while its directory lives outside managed sources,
|
||||
so updates and reloads retain values. Settings-change notifications must not enter the catalog
|
||||
reload path: that path disposes the plugin and would destroy open drafts after every save.
|
||||
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
|
||||
+43
-6
@@ -115,7 +115,18 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
Provider snapshots are views of the catalogues needed for a target. Before looking up cached
|
||||
results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`.
|
||||
The provider owns equivalence: equal keys must mean the same availability, models and modes,
|
||||
including the effective configuration and execution environment. `force` does not change identity.
|
||||
Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's
|
||||
current host clients share across directories; configured provider identities remain isolated.
|
||||
|
||||
The key chooses storage, never execution. Availability and catalogue discovery receive the actual
|
||||
`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another
|
||||
target can share its result. Runtime-aware adapters must use that target for both probing and key
|
||||
resolution. An explicit home-directory workspace is distinct from a semantic global request.
|
||||
Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider).
|
||||
|
||||
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
|
||||
availability check and covers that check plus the complete catalog probe. Providers that make
|
||||
@@ -124,15 +135,41 @@ aborts the shared refresh signal at the deadline. Providers name active catalog
|
||||
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
|
||||
that were still active when the deadline expired.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration.
|
||||
Keys are resolved on each read so project configuration can select a different cached catalogue.
|
||||
Selector opening may read a loading or stale query, but does not force provider probing.
|
||||
|
||||
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
|
||||
Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute
|
||||
another model. Creation reads the caller's host and directory directly; an earlier global snapshot
|
||||
must not settle a project form's initial selection.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references`
|
||||
receive directory-to-hash announcements; an unknown hash uses the existing snapshot request.
|
||||
The manager detaches each fresh result once and publishes its content identity with the entry;
|
||||
readers share these values read-only. Each target keeps its published snapshot until membership,
|
||||
content or discovery freshness changes. All affected targets commit before subscribers run;
|
||||
subscriber failures are logged without changing discovery or configuration outcomes.
|
||||
The session compares both sides of a committed transition under the client's current visibility
|
||||
policy and sends only visible changes. It combines result identities with the client's encoding
|
||||
and icon policy, without traversing models. Reference hashes exclude freshness;
|
||||
legacy embedded hashes include it. Only responses that send a body compact the catalogue.
|
||||
Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt`
|
||||
remains the time the response or announcement was generated. Refresh keeps settled entries visible
|
||||
until the next result, so unchanged discovery sends freshness without another model body.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate
|
||||
directory/hash/freshness records under the same byte budget. Missing bodies share a React Query
|
||||
request; directory queries cancel superseded pulls before accepting pushes. Default SDK clients
|
||||
keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots.
|
||||
Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
Settings refresh invalidates requested providers across known targets and refreshes them in their
|
||||
actual execution context, so every connected client receives the refreshed view. Discovery shares
|
||||
work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates
|
||||
only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation
|
||||
leaves installed reads untouched until commit. Plugin replacement uses registration runtime
|
||||
identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup
|
||||
promise for completion: equal results, including equal discovery timestamps, emit no transition.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ The bar is four questions:
|
||||
|
||||
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
|
||||
|
||||
For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before
|
||||
exercising contributions. Include the boundary tests and both runtime loaders in the evidence.
|
||||
|
||||
## Evidence
|
||||
|
||||
Evidence is something someone else can look at:
|
||||
|
||||
@@ -65,12 +65,18 @@ Codex hook mapping:
|
||||
- `PermissionRequest` → `needs-input`
|
||||
- `Stop` → `idle`
|
||||
|
||||
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
|
||||
OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions.
|
||||
|
||||
- `session.status` with `busy` or `retry` → `running`
|
||||
- `session.status` with `idle` → `idle`
|
||||
- `permission.asked` → `needs-input`
|
||||
- `permission.replied` → `running`
|
||||
| OpenCode event | Generation | Activity |
|
||||
| ----------------------------------------------------------- | ---------- | ----------- |
|
||||
| `session.status` with `busy` or `retry` | 1 | running |
|
||||
| `session.status` with `idle` | 1 | idle |
|
||||
| `session.execution.started` | 2 | running |
|
||||
| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle |
|
||||
| `permission.asked` | Both | needs-input |
|
||||
| `permission.replied` | Both | running |
|
||||
|
||||
The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads.
|
||||
|
||||
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj
|
||||
live prefix.
|
||||
|
||||
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
||||
send-now, and the automatic queue drain in `HostRuntime` — goes through
|
||||
send-now, and the host runtime's automatic queue drain — goes through
|
||||
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
|
||||
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
|
||||
footer, and permanently drops attachments because the daemon does not echo them back.
|
||||
@@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries.
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
|
||||
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
|
||||
mounted.
|
||||
second running state. Disconnect preserves the last replicated turn until cache or network hydration
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
|
||||
+2
-1
@@ -346,7 +346,8 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Patching The Web Runtime
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE=
|
||||
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
|
||||
|
||||
Generated
+57
-30
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -13059,6 +13059,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -34467,7 +34474,6 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -36190,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37255,12 +37261,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/server": "0.7.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37507,10 +37513,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37521,7 +37527,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37764,7 +37770,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38660,7 +38666,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38689,26 +38695,33 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
|
||||
@@ -38722,20 +38735,34 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/protocol/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38953,16 +38980,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38988,6 +39015,7 @@
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
@@ -39003,7 +39031,6 @@
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
@@ -39840,7 +39867,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectAgentIdle,
|
||||
expectAgentReadyToInterrupt,
|
||||
expectAgentSurfacesIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectRunningAgentChrome,
|
||||
expectVisibleAgentSurfacesIdle,
|
||||
} from "../support/helpers/agent-stream";
|
||||
@@ -549,9 +550,18 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForHeldServerMessage();
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
await gate.waitForAgentStreamEvent("turn_started");
|
||||
gate.releaseHeldServerMessage();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
await gate.drop();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setServerMessageSuppressed("agent_status", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
gate.restoreFresh();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -991,7 +1001,7 @@ test.describe("Agent message submission", () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "Keep running until the queued turn is ready.");
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await expectRunningAgentChrome(page, title);
|
||||
await queueMessage(page, secondPrompt);
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -216,7 +216,9 @@ test.describe("Agent stream UI", () => {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
// Reasoning rows collapse when the mock starts its next assistant response. Wait past
|
||||
// that transition so their temporary height cannot satisfy the scroll-away setup.
|
||||
await awaitAssistantMessage(page, "Now I have a clearer picture.");
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import {
|
||||
startIsolatedHostDaemon,
|
||||
type IsolatedHostDaemon,
|
||||
} from "../support/helpers/isolated-host-daemon";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
|
||||
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
|
||||
reconnectHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const daemon = await startIsolatedHostDaemon("background-reconnect");
|
||||
try {
|
||||
await provide(daemon);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
async function captureHost({
|
||||
page,
|
||||
testInfo,
|
||||
name,
|
||||
}: {
|
||||
page: Page;
|
||||
testInfo: TestInfo;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) });
|
||||
}
|
||||
|
||||
async function refocusConnectedTab(page: Page): Promise<void> {
|
||||
await setTabVisibility(page, "visible");
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
|
||||
await page.goto("/");
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Reconnect QA",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
]);
|
||||
await page.goto(`/settings/hosts/${daemon.serverId}/host`);
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function restartHostWhileHidden(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
|
||||
const reconnectedSocket = page.waitForEvent("websocket", {
|
||||
timeout: 30_000,
|
||||
predicate: (socket) => new URL(socket.url()).port === String(daemon.port),
|
||||
});
|
||||
await daemon.restart();
|
||||
await reconnectedSocket;
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
expect(await page.evaluate(() => document.visibilityState)).toBe("hidden");
|
||||
}
|
||||
|
||||
test("a hidden tab reconnects after daemon restart and stays connected on refocus", async ({
|
||||
page,
|
||||
reconnectHost,
|
||||
}, testInfo) => {
|
||||
await openHostOverview(page, reconnectHost);
|
||||
await captureHost({ page, testInfo, name: "before-hide" });
|
||||
await setTabVisibility(page, "hidden");
|
||||
await restartHostWhileHidden(page, reconnectHost);
|
||||
await captureHost({ page, testInfo, name: "reconnected-while-hidden" });
|
||||
await refocusConnectedTab(page);
|
||||
await captureHost({ page, testInfo, name: "reconnected-after-refocus" });
|
||||
});
|
||||
@@ -311,8 +311,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -49,30 +51,52 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "codex-mode-preferences",
|
||||
});
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd })).entries.find(
|
||||
(entry) => entry.provider === "codex",
|
||||
)?.status,
|
||||
)
|
||||
.toBe("ready");
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd });
|
||||
const model = snapshot.entries
|
||||
.find((entry) => entry.provider === "codex")
|
||||
?.models?.find((candidate) => candidate.thinkingOptions?.length);
|
||||
if (!model?.thinkingOptions?.[0])
|
||||
throw new Error("Codex catalogue must expose a model with thinking options");
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, modelId, thinkingOptionId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: modelId,
|
||||
mode: "auto",
|
||||
thinkingByModel: { [modelId]: thinkingOptionId },
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
mock: {
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
modelId: model.id,
|
||||
thinkingOptionId: model.thinkingOptions[0].id,
|
||||
},
|
||||
);
|
||||
return model.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readCodexModePreference(page: Page): Promise<unknown> {
|
||||
@@ -156,9 +180,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
@@ -193,16 +216,15 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
const model = await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Codex live mode preference e2e",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4-mini",
|
||||
model,
|
||||
});
|
||||
|
||||
await openAgentRoute(page, {
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// `sleep` keeps the terminal alive long enough for the UI to attach and
|
||||
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
|
||||
// Wait for input until project cleanup kills the terminal. A fixed lifetime
|
||||
// can expire before a busy browser attaches or finishes revisiting the composer.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
|
||||
@@ -25,14 +25,13 @@ import {
|
||||
//
|
||||
// The sentinel becomes `$0` (a positional shell parameter, not string
|
||||
// interpolation into the script source), so the prompt reaches the process
|
||||
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
|
||||
// long enough for the UI to attach and render the output before it exits —
|
||||
// a bare `echo` exits before the frontend subscribes to its output.
|
||||
// exactly as typed with no injection risk. `cat` waits for input until project
|
||||
// cleanup kills the terminal, so attachment does not race a fixed process lifetime.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-prompt",
|
||||
name: "Echo Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
// No sentinel anywhere: takes no prompt, composer goes read-only.
|
||||
@@ -40,7 +39,7 @@ const BARE_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-bare",
|
||||
name: "Bare Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "echo bare-launch-static-line; sleep 10"],
|
||||
args: ["-c", "echo bare-launch-static-line; exec cat"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: launching a terminal", () => {
|
||||
@@ -88,7 +87,7 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
|
||||
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
chooseModel,
|
||||
reselectModel,
|
||||
startWithoutRememberedModel,
|
||||
expectCreatedModelAgents,
|
||||
expectRememberedModel,
|
||||
expectSavedSelection,
|
||||
} from "../support/helpers/model-memory";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
test("remembers real Codex GPT-6 Astra after repeated successful workspace creation", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "codex-astra-memory-" });
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "codex-astra-memory" });
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd: workspace.repoPath })).entries.find(
|
||||
(entry) => entry.provider === "codex",
|
||||
)?.status,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe("ready");
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd: workspace.repoPath });
|
||||
const codex = snapshot.entries.find((entry) => entry.provider === "codex");
|
||||
expect(codex?.status).toBe("ready");
|
||||
const astra = codex?.models?.find((model) => model.id === "gpt-6-astra");
|
||||
expect(astra?.id).toBe("gpt-6-astra");
|
||||
const label = astra!.label;
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
for (const [index, select] of [chooseModel, reselectModel].entries()) {
|
||||
const count = index + 1;
|
||||
await test.step(`create workspace ${count} and return`, async () => {
|
||||
await select(page, "codex", label);
|
||||
await expectSavedSelection(page, "codex", "gpt-6-astra");
|
||||
await submitNewWorkspacePrompt(page, "Reply with OK. Do not use tools or change files.");
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectCreatedModelAgents(page, client, "codex", "gpt-6-astra", count);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "OK", visible: true }),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, "codex", "gpt-6-astra");
|
||||
await expectRememberedModel(page, label);
|
||||
});
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("astra-remembered.png") });
|
||||
} finally {
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdtemp, rename, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { seedModelProvider } from "../support/helpers/agent-profiles";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
import {
|
||||
chooseModel,
|
||||
reselectModel,
|
||||
startWithoutRememberedModel,
|
||||
expectSavedSelection,
|
||||
expectCreatedModelAgents,
|
||||
expectRememberedModel,
|
||||
} from "../support/helpers/model-memory";
|
||||
|
||||
const PROVIDER = "model-memory-diagnostic";
|
||||
const MODEL = "pi-profile-model";
|
||||
const LABEL = "Pi profile model";
|
||||
|
||||
async function expectProviderStatus(client: DaemonClient, cwd: string | undefined, status: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot(cwd ? { cwd } : undefined)).entries.find(
|
||||
(entry) => entry.provider === PROVIDER,
|
||||
)?.status,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(status);
|
||||
}
|
||||
|
||||
async function prepareCatalogState(
|
||||
client: DaemonClient,
|
||||
executable: string,
|
||||
cwd: string,
|
||||
hostStatus: "ready" | "unavailable",
|
||||
) {
|
||||
await expectProviderStatus(client, undefined, "ready");
|
||||
await expectProviderStatus(client, cwd, "ready");
|
||||
await rename(executable, `${executable}.parked`);
|
||||
await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
|
||||
await expectProviderStatus(client, undefined, "unavailable");
|
||||
await rename(`${executable}.parked`, executable);
|
||||
if (hostStatus === "ready") await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
|
||||
await client.refreshProvidersSnapshot({ cwd, providers: [PROVIDER] });
|
||||
await expectProviderStatus(client, cwd, "ready");
|
||||
await expectProviderStatus(client, undefined, hostStatus);
|
||||
}
|
||||
|
||||
for (const hostStatus of ["ready", "unavailable"] as const) {
|
||||
test(`remembers the manually selected model after two creations with a ${hostStatus} host catalog and ready project catalog`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-memory-scope-" });
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-model-memory-bin-"));
|
||||
const executable = path.join(binDir, "provider-node");
|
||||
await symlink(process.execPath, executable);
|
||||
const provider = await seedModelProvider({
|
||||
id: PROVIDER,
|
||||
label: "Model memory diagnostic",
|
||||
extends: "pi",
|
||||
command: [executable, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [{ id: MODEL, label: LABEL, description: "Remembered selection diagnostic" }],
|
||||
});
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-memory" });
|
||||
try {
|
||||
await prepareCatalogState(client, executable, workspace.repoPath, hostStatus);
|
||||
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
for (const [index, select] of [chooseModel, reselectModel].entries()) {
|
||||
const count = index + 1;
|
||||
await test.step(`create workspace ${count} and return`, async () => {
|
||||
await select(page, PROVIDER, LABEL);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await submitNewWorkspacePrompt(page, "Remember this model on the next workspace");
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectCreatedModelAgents(page, client, PROVIDER, MODEL, count);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
});
|
||||
}
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
} finally {
|
||||
await client.close();
|
||||
await provider.restore();
|
||||
await workspace.cleanup();
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openGlobalNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { expectRememberedModel, expectSavedSelection } from "../support/helpers/model-memory";
|
||||
|
||||
const PROVIDER = "remembered-model-recovery";
|
||||
const MODEL = "remembered-model";
|
||||
const LABEL = "Remembered model";
|
||||
|
||||
async function rememberModel(page: Page) {
|
||||
await page.addInitScript(
|
||||
({ provider, model }) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: { [provider]: { model } },
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ provider: PROVIDER, model: MODEL },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectRecoveredModelInPicker(page: Page) {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||||
await drillIntoProvider(page, PROVIDER);
|
||||
await expect(
|
||||
page.getByTestId("combobox-desktop-container").getByText(LABEL, { exact: true }),
|
||||
).toBeVisible();
|
||||
await closeModelPicker(page);
|
||||
}
|
||||
|
||||
async function setProviderAvailability(client: DaemonClient, cwd: string, available: boolean) {
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[PROVIDER]: {
|
||||
command: [available ? process.execPath : "/missing-paseo-diagnostic-provider"],
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const scope of [undefined, cwd]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await client.getProvidersSnapshot(scope ? { cwd: scope } : undefined);
|
||||
return snapshot.entries.find((entry) => entry.provider === PROVIDER)?.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(available ? "ready" : "unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
test("restores the remembered model when a provider recovers after New workspace opens", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-recovery-" });
|
||||
const provider = await seedModelProvider({
|
||||
id: PROVIDER,
|
||||
label: "Remembered provider",
|
||||
models: [{ id: MODEL, label: LABEL, description: "Model restoration diagnostic" }],
|
||||
command: [process.execPath],
|
||||
});
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-recovery" });
|
||||
try {
|
||||
await rememberModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await setProviderAvailability(client, workspace.repoPath, false);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector").filter({ visible: true }),
|
||||
).not.toContainText("Loading...");
|
||||
|
||||
await setProviderAvailability(client, workspace.repoPath, true);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRecoveredModelInPicker(page);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
} finally {
|
||||
await client.close();
|
||||
await provider.restore();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -11,8 +12,8 @@ import {
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -118,7 +119,10 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -120,7 +121,10 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeExample(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function copyAndPaste(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(page.getByText("Text copied", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo");
|
||||
await page.getByRole("textbox", { name: "Paste here" }).focus();
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo");
|
||||
}
|
||||
|
||||
async function scrollToLastRow(page: Page) {
|
||||
await page.getByRole("button", { name: "Row 1", exact: true }).hover();
|
||||
await page.mouse.wheel(0, 7000);
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "Row 100", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectSingleInset(page: Page, left = "24px", bottom = "24px") {
|
||||
const form = await page.getByTestId("modal-example-form").boundingBox();
|
||||
const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox();
|
||||
expect(form).not.toBeNull();
|
||||
expect(input?.x).toBe(form?.x);
|
||||
// The real content View owns the inset, not a third-party contentContainerStyle.
|
||||
const padding = await page.getByTestId("modal-example-form").evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left, bottom });
|
||||
}
|
||||
|
||||
async function expectFullBleed(page: Page) {
|
||||
const padding = await page
|
||||
.getByRole("button", { name: "Row 1", exact: true })
|
||||
.evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left: "0px", bottom: "0px" });
|
||||
}
|
||||
|
||||
async function exerciseBodies(page: Page) {
|
||||
await openExample(page, "Form");
|
||||
await expectSingleInset(page);
|
||||
await copyAndPaste(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "Custom inset");
|
||||
await expectSingleInset(page, "8px", "40px");
|
||||
await closeExample(page);
|
||||
await openExample(page, "Full bleed");
|
||||
await expectFullBleed(page);
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "ScrollView");
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "FlatList");
|
||||
await page.getByRole("button", { name: "Jump to last row", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await closeExample(page);
|
||||
await openExample(page, "Horizontal");
|
||||
await page.getByRole("button", { name: "Overview", exact: true }).hover();
|
||||
await page.mouse.wheel(900, 0);
|
||||
await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "System logs", exact: true }).click();
|
||||
await closeExample(page);
|
||||
await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const test = base.extend<{ modalExample: Page }>({
|
||||
modalExample: async ({ page, context }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("modal-ui");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await provide(page);
|
||||
} finally {
|
||||
await client.removePlugin("modal-ui-example").catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function openWideExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
||||
await cdp.send("Browser.setPermission", {
|
||||
browserContextId: targetInfo.browserContextId,
|
||||
permission: { name: "clipboard-write" },
|
||||
setting: "denied",
|
||||
origin: new URL(page.url()).origin,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async () =>
|
||||
(await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state,
|
||||
),
|
||||
).toBe("denied");
|
||||
await openExample(page, "Form");
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await closeExample(page);
|
||||
} finally {
|
||||
await cdp.detach();
|
||||
}
|
||||
}
|
||||
|
||||
test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({
|
||||
modalExample: page,
|
||||
}) => {
|
||||
await openWideExamples(page);
|
||||
await test.step("wide dialogs", () => exerciseBodies(page));
|
||||
await openCompactExamples(page);
|
||||
await test.step("compact sheets", () => exerciseBodies(page));
|
||||
await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page));
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
test,
|
||||
openRequirementHost,
|
||||
expectAppMismatch,
|
||||
correctRequirementAndReload,
|
||||
rejectDaemonMismatch,
|
||||
} from "../support/helpers/plugin-requirements";
|
||||
|
||||
test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({
|
||||
page,
|
||||
requirementHost,
|
||||
}) => {
|
||||
await openRequirementHost(page, requirementHost);
|
||||
await expectAppMismatch(page);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
await rejectDaemonMismatch(page, requirementHost.directory);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
test,
|
||||
openDisplaySettings,
|
||||
openCompactDisplaySettings,
|
||||
groupByWorkspace,
|
||||
hideMetadata,
|
||||
reloadSavedDisplaySettings,
|
||||
editTitle,
|
||||
replaceTitle,
|
||||
saveTitle,
|
||||
expectTitleRequired,
|
||||
expectSavedTitle,
|
||||
changeMetadataFromAnotherClient,
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await groupByWorkspace(page);
|
||||
await hideMetadata(page);
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
|
||||
test("invalid titles stay editable until a valid title is saved", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await editTitle(page, "");
|
||||
await saveTitle(page);
|
||||
await expectTitleRequired(page);
|
||||
await replaceTitle(page, "My monitor");
|
||||
await saveTitle(page);
|
||||
await expectSavedTitle(page, "My monitor");
|
||||
});
|
||||
|
||||
test("another client's save preserves a conflicting draft", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await editTitle(page, "Unsaved title");
|
||||
await changeMetadataFromAnotherClient(page);
|
||||
await saveTitle(page);
|
||||
await expectConflictPreservesDraft(page, "Unsaved title");
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("Back and plugin re-enable restore the settings screen", async ({
|
||||
page,
|
||||
settingsPlugin,
|
||||
}) => {
|
||||
await openCompactDisplaySettings(page);
|
||||
await groupByWorkspace(page);
|
||||
await hideMetadata(page);
|
||||
await returnAndReopenSettings(page);
|
||||
await settingsPlugin.disablePlugin("settings-example");
|
||||
await expectSettingsUnavailable(page);
|
||||
await settingsPlugin.enablePlugin("settings-example");
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -53,7 +54,10 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -29,7 +30,8 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
@@ -199,7 +201,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
repoPrefix: "plugin-panel-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Native sheet gestures
|
||||
|
||||
Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded.
|
||||
Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there.
|
||||
Set `SHEET_QA_SERVER_ID` to that host's server ID, then run from the repository root:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/gestures.android.ad \
|
||||
--env EXAMPLES_URL="paseo://h/${SHEET_QA_SERVER_ID}/plugin/modal-ui-example/surface/main" \
|
||||
--record-video --artifacts-dir .dev/sheet-qa/plugin
|
||||
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model.android.ad \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--record-video --artifacts-dir .dev/sheet-qa/model
|
||||
```
|
||||
|
||||
The plugin journey checks body dismissal, reopening, expansion and last-row reachability with SDK
|
||||
ScrollView and FlatList, programmatic scrolling after expansion, downward list scrolling, and
|
||||
horizontal tab selection. The model journey checks body dismissal and the nested sheet's return to
|
||||
its parent without selecting a model or submitting a prompt. It does not assert catalog contents.
|
||||
|
||||
These scripts live outside the default mobile suite because they require the installed example and
|
||||
an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup.
|
||||
|
||||
## Recorded verification
|
||||
|
||||
Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript.
|
||||
Plugin tests used an isolated daemon. Recordings show the same body drag
|
||||
[before](evidence/android-before.mp4) and [after](evidence/android-after.mp4) the fix.
|
||||
The [browser recording](evidence/browser.webm) covers the existing plugin modal journey at wide and
|
||||
compact sizes.
|
||||
|
||||
```text
|
||||
Baseline dismissal regression:
|
||||
failed at step 6: wait timed out for selector: label="Open ScrollView"
|
||||
Current surface: Bottom sheet handle, Bottom Sheet, Close, Row 1.
|
||||
|
||||
Fixed dismissal regression: 1 passed (6.47s)
|
||||
Full plugin gesture journey: 1 passed (44.3s)
|
||||
Model sheet dismissal/reopen journey: 1 passed (13.7s)
|
||||
Browser plugin-modal-body.spec.ts: 1 passed (33.4s)
|
||||
Root typecheck, lint and format: passed
|
||||
```
|
||||
|
||||
The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's
|
||||
provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection
|
||||
was not. iOS and Electron were not exercised.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
# Install plugin-examples/modal-ui on the connected host and set EXAMPLES_URL to
|
||||
# paseo://h/<serverId>/plugin/modal-ui-example/surface/main.
|
||||
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
|
||||
context platform=android timeout=120000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}" "${EXAMPLES_URL}"
|
||||
wait "label=\"Open ScrollView\"" 20000
|
||||
press "label=\"Open ScrollView\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 980 0 820 500
|
||||
wait "label=\"Open ScrollView\"" 3000
|
||||
|
||||
# The caller-owned ScrollView expands with a body drag and reaches its final row.
|
||||
press "label=\"Open ScrollView\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 980 0 -600 500
|
||||
wait "label=\"Row 10\"" 3000
|
||||
scroll bottom
|
||||
wait "label=\"Row 100\"" 3000
|
||||
press "role=\"button\" label=\"Row 100\""
|
||||
press "label=\"Close\""
|
||||
wait "text=\"Selected: Row 100\"" 3000
|
||||
|
||||
# FlatList has the same body dismissal, then supports refs after expansion.
|
||||
press "label=\"Open FlatList\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 1100 0 700 500
|
||||
wait "label=\"Open FlatList\"" 3000
|
||||
press "label=\"Open FlatList\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 1140 0 -750 500
|
||||
wait "label=\"Row 9\"" 3000
|
||||
press "label=\"Jump to last row\""
|
||||
wait "label=\"Row 100\"" 3000
|
||||
# Dragging down while scrolled moves the list, leaving the sheet open.
|
||||
gesture pan 540 650 0 900 500
|
||||
wait "label=\"Row 88\"" 3000
|
||||
is visible "label=\"Close\""
|
||||
press "label=\"Close\""
|
||||
wait "label=\"Open Horizontal\"" 3000
|
||||
|
||||
# Horizontal tabs still receive gestures inside the host-owned vertical body.
|
||||
press "label=\"Open Horizontal\""
|
||||
wait "label=\"Overview\"" 10000
|
||||
gesture pan 960 980 -880 0 500
|
||||
wait stable 300 5000
|
||||
press "role=\"button\" label=\"System logs\""
|
||||
wait stable 300 5000
|
||||
press "label=\"Close\""
|
||||
wait "text=\"Selected: System logs\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,22 @@
|
||||
# FORM_URL is paseo://new?serverId=<connected-serverId>.
|
||||
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "id=\"combined-model-selector\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"agent-controls-model\"" 10000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"combined-model-selector\"" 3000
|
||||
|
||||
# Reopen, dismiss the nested model browser, then dismiss its restored parent.
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"agent-controls-model\"" 10000
|
||||
press "id=\"agent-controls-model\""
|
||||
wait stable 300 5000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"agent-controls-model\"" 3000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"combined-model-selector\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Open an idle terminal with an empty prompt and hide its keyboard before running.
|
||||
// The app must already run the checkout/build being verified against a real daemon.
|
||||
const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug";
|
||||
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android");
|
||||
const session = "terminal-keyboard-android";
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function run(binary, args) {
|
||||
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 });
|
||||
}
|
||||
function ad(...args) {
|
||||
return run("agent-device", [...args, "--session", session]);
|
||||
}
|
||||
function adb(...args) {
|
||||
return run("adb", ["-s", serial, ...args]);
|
||||
}
|
||||
function keyboardState() {
|
||||
const state = adb("shell", "dumpsys", "input_method");
|
||||
const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? "";
|
||||
return {
|
||||
shown: /^ mInputShown=true$/m.test(state),
|
||||
// The native object identity survives layout/flag changes but not a remount.
|
||||
input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
async function waitForKeyboard(shown) {
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = keyboardState();
|
||||
stable = state.shown === shown ? stable + 1 : 0;
|
||||
if (stable === 3) return state;
|
||||
await setTimeout(100);
|
||||
}
|
||||
throw new Error(`Keyboard did not settle at shown=${shown}`);
|
||||
}
|
||||
function keyboardShift() {
|
||||
const state = adb("shell", "dumpsys", "window");
|
||||
const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/);
|
||||
const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/);
|
||||
assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar");
|
||||
return Number(navigation[1]) - Number(ime[1]);
|
||||
}
|
||||
function imeEvents() {
|
||||
return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S")
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(`${appId}:`));
|
||||
}
|
||||
function tap(rect, shift) {
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(rect.x + rect.width / 2)),
|
||||
String(Math.round(rect.y + rect.height / 2 - shift)),
|
||||
);
|
||||
}
|
||||
|
||||
let toggle;
|
||||
try {
|
||||
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
|
||||
assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden");
|
||||
// Accessibility snapshots can hang with a real IME open. Measure the controls
|
||||
// while hidden, then project them by Android's actual docked-keyboard inset.
|
||||
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const rectFor = (id) => {
|
||||
const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id);
|
||||
assert.ok(node?.rect, `Open a terminal first: missing ${id}`);
|
||||
return node.rect;
|
||||
};
|
||||
toggle = rectFor("terminal-keyboard-toggle");
|
||||
const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({
|
||||
key,
|
||||
rect: rectFor(`terminal-key-${key}`),
|
||||
}));
|
||||
tap(toggle, 0);
|
||||
const baseline = await waitForKeyboard(true);
|
||||
assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME");
|
||||
const shift = keyboardShift();
|
||||
const before = new Set(imeEvents());
|
||||
ad("screenshot", resolve(artifacts, "before.png"));
|
||||
|
||||
for (const { key, rect } of controls) {
|
||||
tap(rect, shift);
|
||||
const after = await waitForKeyboard(true);
|
||||
const events = imeEvents().filter((line) => !before.has(line));
|
||||
writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n"));
|
||||
writeFileSync(
|
||||
resolve(artifacts, "focus.json"),
|
||||
JSON.stringify({ key, baseline, after }, null, 2),
|
||||
);
|
||||
assert.equal(after.input, baseline.input, `${key} replaced the focused native input`);
|
||||
assert.deepEqual(
|
||||
events.filter((line) => /onRequestHide|onRequestShow/.test(line)),
|
||||
[],
|
||||
`${key} cycled the software keyboard`,
|
||||
);
|
||||
}
|
||||
ad("screenshot", resolve(artifacts, "after.png"));
|
||||
console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests");
|
||||
} finally {
|
||||
if (toggle && keyboardState().shown) {
|
||||
tap(toggle, keyboardShift());
|
||||
await waitForKeyboard(false);
|
||||
}
|
||||
ad("close");
|
||||
run("agent-device", ["daemon", "stop", "--clean"]);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
type ExplorerPanelWorkspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
async function writePanelPlugin(id: string, title: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(client) {
|
||||
client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => <Text>{${JSON.stringify(title)} + " workspace " + workspaceId}</Text> });
|
||||
client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => <Text>Summary content</Text> });
|
||||
client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null });
|
||||
client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function openExplorerMenu(page: Page) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
// The rail's empty background has no accessible role; tab menus are separate.
|
||||
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
|
||||
button: "right",
|
||||
position: { x: 20, y: 2 },
|
||||
});
|
||||
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withExplorerPanelPlugins(
|
||||
page: Page,
|
||||
run: (workspace: ExplorerPanelWorkspace) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" });
|
||||
const first = await writePanelPlugin("explorer-review", "Review");
|
||||
const second = await writePanelPlugin("explorer-other", "Other review");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(first);
|
||||
await client.installDirectoryPlugin(second);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await run(workspace);
|
||||
} finally {
|
||||
await client.removePlugin("explorer-review");
|
||||
await client.removePlugin("explorer-other");
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await rm(first, { recursive: true, force: true });
|
||||
await rm(second, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
|
||||
const launcher = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await testInfo.attach("explorer-panel-menu", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"New tab",
|
||||
"Changes",
|
||||
"Files",
|
||||
"Other review",
|
||||
"Other review summary",
|
||||
"Review",
|
||||
"Review summary",
|
||||
]);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
const updated = await openExplorerMenu(page);
|
||||
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Other review", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("explorer-sidebar-tab-rail")
|
||||
.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectExplorerPanelWithFocusedAgent(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Focused agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("workspace-panel-with-focused-agent", {
|
||||
body: await page.screenshot({
|
||||
path: testInfo.outputPath("workspace-panel-with-focused-agent.png"),
|
||||
}),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { drillIntoProvider, openModelPicker } from "./agent-profiles";
|
||||
|
||||
export async function startWithoutRememberedModel(page: Page) {
|
||||
await page.addInitScript(() => localStorage.removeItem("@paseo:create-agent-preferences"));
|
||||
}
|
||||
|
||||
export async function chooseModel(page: Page, provider: string, label: string) {
|
||||
await openModelPicker(page);
|
||||
await expect(
|
||||
page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectProviderModel(page, provider, label);
|
||||
}
|
||||
|
||||
export async function reselectModel(page: Page, provider: string, label: string) {
|
||||
await expectRememberedModel(page, label);
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||||
await selectProviderModel(page, provider, label);
|
||||
}
|
||||
|
||||
async function selectProviderModel(page: Page, provider: string, label: string) {
|
||||
await drillIntoProvider(page, provider);
|
||||
await page.getByTestId("combobox-desktop-container").getByText(label, { exact: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: `Select model (${label})`, exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSavedSelection(page: Page, provider: string, model: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem("@paseo:create-agent-preferences") ?? "null"),
|
||||
),
|
||||
)
|
||||
.toMatchObject({ provider, providerPreferences: { [provider]: { model } } });
|
||||
}
|
||||
|
||||
export async function expectCreatedModelAgents(
|
||||
page: Page,
|
||||
client: DaemonClient,
|
||||
provider: string,
|
||||
model: string,
|
||||
count: number,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter(({ agent }) => agent.provider === provider)
|
||||
.map(({ agent }) => agent.model);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toEqual(Array(count).fill(model));
|
||||
await expect(page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectRememberedModel(page: Page, label: string) {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: `Select model (${label})`, exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "disablePlugin"
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "on"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import appPackage from "../../../package.json";
|
||||
|
||||
export const pluginRequirements = { paseo: `>=${appPackage.version}` };
|
||||
|
||||
export async function copyPluginExample(name: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-"));
|
||||
try {
|
||||
await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
const manifestPath = path.join(directory, "paseo-plugin.json");
|
||||
const manifest = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
// Exercise example UI against the checkout runtime before its release version is bumped.
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({ ...manifest, requirements: pluginRequirements }),
|
||||
);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { spawnTsx, killProcessTree } from "./spawn-node";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const test = base.extend<{
|
||||
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
|
||||
}>({
|
||||
requirementHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-"));
|
||||
await writeRequirements(directory, "^99.0.0");
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.ts"),
|
||||
`export default function(client) {
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const daemon = spawnTsx(
|
||||
path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"),
|
||||
["99.0.0"],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Versioned daemon startup timed out")),
|
||||
30_000,
|
||||
);
|
||||
let output = "";
|
||||
daemon.stdout?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.stderr?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.once("error", ready.reject);
|
||||
daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`)));
|
||||
daemon.once("message", (message) => {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message === null ||
|
||||
!("port" in message) ||
|
||||
typeof message.port !== "number" ||
|
||||
!("serverId" in message) ||
|
||||
typeof message.serverId !== "string"
|
||||
) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve({ port: message.port, serverId: message.serverId });
|
||||
});
|
||||
try {
|
||||
const host = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: host.port,
|
||||
clientIdPrefix: "requirements",
|
||||
});
|
||||
try {
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await provide({ ...host, directory, client });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(daemon);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function writeRequirements(directory: string, paseo: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" });
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "plugins");
|
||||
}
|
||||
|
||||
export async function expectAppMismatch(page: Page) {
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function correctRequirementAndReload(page: Page, directory: string) {
|
||||
await writeRequirements(directory, pluginRequirements.paseo);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your app is/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function rejectDaemonMismatch(page: Page, directory: string) {
|
||||
await writeRequirements(directory, ">=100.0.0");
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
void e2eWorker;
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export async function openDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openPluginScreen(page);
|
||||
}
|
||||
export async function openCompactDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, new URL(page.url()).pathname);
|
||||
await openPluginScreen(page);
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function hideMetadata(page: Page) {
|
||||
await page.getByRole("switch", { name: "Show metadata" }).click();
|
||||
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function reloadSavedDisplaySettings(page: Page) {
|
||||
await page.reload();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked();
|
||||
}
|
||||
export async function editTitle(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: "Edit title", exact: true }).click();
|
||||
await replaceTitle(page, title);
|
||||
}
|
||||
export async function replaceTitle(page: Page, title: string) {
|
||||
await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title);
|
||||
}
|
||||
export async function saveTitle(page: Page) {
|
||||
await page.getByRole("button", { name: "Save title", exact: true }).click();
|
||||
}
|
||||
export async function expectTitleRequired(page: Page) {
|
||||
await expect(page.getByText(/Enter a title/).first()).toBeVisible();
|
||||
}
|
||||
export async function expectSavedTitle(page: Page, title: string) {
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0);
|
||||
}
|
||||
export async function changeMetadataFromAnotherClient(page: Page) {
|
||||
const peer = await page.context().newPage();
|
||||
try {
|
||||
await peer.goto(page.url());
|
||||
await hideMetadata(peer);
|
||||
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await peer.close();
|
||||
}
|
||||
}
|
||||
export async function expectConflictPreservesDraft(page: Page, title: string) {
|
||||
await expect(page.getByText(/Settings changed on another client/)).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue(
|
||||
title,
|
||||
);
|
||||
}
|
||||
export async function discardTitle(page: Page) {
|
||||
await page.getByRole("button", { name: "Discard", exact: true }).click();
|
||||
await expectSavedTitle(page, "Agent monitor");
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
|
||||
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -28,12 +28,12 @@ import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { PluginCommandCenterActions } from "@/plugins/command-center/registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
bindHostRuntimeAppState,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
@@ -372,6 +373,11 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
return bindHostRuntimeAppState(store, AppState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
@@ -545,11 +551,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
let themedSidebarChrome = sidebarChrome;
|
||||
if (isWeb) {
|
||||
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{sidebarChrome}
|
||||
{themedSidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{usesCompactExplorerHost ? (
|
||||
@@ -569,42 +579,48 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
// Native panel gesture hosts outlive appearance keys, like native navigators.
|
||||
// Their tracked styles update in place; web numeric styles still need remounting.
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
|
||||
pointerEvents="box-none"
|
||||
style={layoutStyles.windowSidebarToggle}
|
||||
>
|
||||
<WindowSidebarMenuToggle />
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<QuittingOverlay />
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
|
||||
pointerEvents="box-none"
|
||||
style={layoutStyles.windowSidebarToggle}
|
||||
>
|
||||
<WindowSidebarMenuToggle />
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
</AppearanceStyleBoundary>
|
||||
{isCompactLayout ? themedSidebarChrome : null}
|
||||
<AppearanceStyleBoundary>
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -666,7 +682,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
@@ -877,11 +893,15 @@ const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -896,6 +916,7 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
|
||||
</ThemedStack>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function PluginSettingsRoute() {
|
||||
const { serverId, pluginId, screenId } = useLocalSearchParams<{
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
screenId: string;
|
||||
}>();
|
||||
const view = useMemo(
|
||||
() => ({ kind: "plugin" as const, serverId, pluginId, screenId }),
|
||||
[serverId, pluginId, screenId],
|
||||
);
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
activeTurn: overrides.activeTurn ?? null,
|
||||
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,30 +5,26 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("adds the bottom inset to compact sheet footers", () => {
|
||||
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: true,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ footerPaddingBottom: 46 });
|
||||
).toEqual({ footerPaddingBottom: 34 });
|
||||
});
|
||||
|
||||
it("adds the bottom inset to compact sheet content when there is no footer", () => {
|
||||
it("assigns safe clearance to the body only without a footer", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ contentPaddingBottom: 58 });
|
||||
).toEqual({ contentPaddingBottom: 34 });
|
||||
});
|
||||
|
||||
it("does not add a safe-area band above the compact keyboard", () => {
|
||||
@@ -37,8 +33,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
isCompact: true,
|
||||
isKeyboardVisible: true,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
@@ -50,8 +44,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
isCompact: false,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
|
||||
@@ -2,8 +2,6 @@ export interface CompactSheetSafeAreaPaddingInput {
|
||||
isCompact: boolean;
|
||||
isKeyboardVisible: boolean;
|
||||
hasFooter: boolean;
|
||||
baseContentPadding: number;
|
||||
baseFooterPadding: number;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
@@ -38,8 +36,6 @@ export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
isKeyboardVisible,
|
||||
hasFooter,
|
||||
baseContentPadding,
|
||||
baseFooterPadding,
|
||||
safeAreaBottom,
|
||||
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
|
||||
if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) {
|
||||
@@ -47,8 +43,8 @@ export function getCompactSheetSafeAreaPadding({
|
||||
}
|
||||
|
||||
if (hasFooter) {
|
||||
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
|
||||
return { footerPaddingBottom: safeAreaBottom };
|
||||
}
|
||||
|
||||
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
|
||||
return { contentPaddingBottom: safeAreaBottom };
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -67,6 +67,7 @@ export interface SheetHeader {
|
||||
search?: SheetHeaderSearch;
|
||||
}
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -79,6 +80,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopCard: {
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
@@ -445,8 +447,13 @@ export interface AdaptiveModalSheetProps {
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
/** Bound an author-owned list without changing content-sized first-party dialogs. */
|
||||
desktopHeight?: DimensionValue;
|
||||
/** Whether the host supplies the scroll container. Caller-owned lists still share sheet gestures. */
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/** Full body viewport below the header, including space beyond the content. */
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
@@ -466,10 +473,12 @@ export function AdaptiveModalSheet({
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
desktopHeight,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
contentStyle,
|
||||
sizeContentToCurrentSnapPoint = false,
|
||||
bodyStyle,
|
||||
sizeContentToCurrentSnapPoint = true,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -484,43 +493,22 @@ export function AdaptiveModalSheet({
|
||||
isCompact: isMobile,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
baseFooterPadding: theme.spacing[3],
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing],
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile],
|
||||
);
|
||||
const compactContentStyle = useMemo(
|
||||
() => [
|
||||
contentStyle,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom, contentStyle],
|
||||
);
|
||||
const compactStaticContentStyle = useMemo(
|
||||
() => [styles.compactStaticContent, compactContentStyle],
|
||||
[compactContentStyle],
|
||||
);
|
||||
const desktopScrollContentStyle = useMemo(
|
||||
() => [styles.contentGrow, contentStyle],
|
||||
[contentStyle],
|
||||
);
|
||||
const desktopStaticContentStyle = useMemo(
|
||||
() => [styles.desktopStaticContent, contentStyle],
|
||||
[contentStyle],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [
|
||||
styles.footer,
|
||||
footerContainerStyle,
|
||||
compactSafeAreaPadding.footerPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null;
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
@@ -554,8 +542,12 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||
[desktopMaxWidth],
|
||||
() => [
|
||||
styles.desktopCard,
|
||||
desktopHeight != null && { height: desktopHeight },
|
||||
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
|
||||
],
|
||||
[desktopMaxWidth, desktopHeight],
|
||||
);
|
||||
const desktopOverlayStyle = useMemo(
|
||||
() => [
|
||||
@@ -620,18 +612,27 @@ export function AdaptiveModalSheet({
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SheetContent style={compactContentStyle}>{children}</SheetContent>
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<SheetContent style={compactStaticContentStyle}>{children}</SheetContent>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -662,24 +663,26 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopStaticStyle =
|
||||
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
|
||||
const cardInner = (
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.contentGrow}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<SheetContent style={desktopScrollContentStyle}>{children}</SheetContent>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<SheetContent style={desktopStaticContentStyle}>{children}</SheetContent>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
) : (
|
||||
<SheetContent style={[desktopStaticStyle, contentStyle]}>{children}</SheetContent>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -372,7 +373,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
||||
const openProject = useOpenProject(hostId);
|
||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||
const upsertProject = useSessionStore((store) => store.upsertProject);
|
||||
const upsertProject = useCallback(
|
||||
(
|
||||
targetServerId: string,
|
||||
project: Parameters<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { appearanceStyleBoundaryKey } from "./appearance-style-boundary";
|
||||
@@ -14,3 +16,58 @@ describe("appearanceStyleBoundaryKey", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// This is a placement contract against the production JSX, not a simulated native mount.
|
||||
// Native Gesture Handler/Fabric lifetime behavior is verified on the release app.
|
||||
describe("retained native gesture host appearance placement", () => {
|
||||
it.each([
|
||||
["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]],
|
||||
[
|
||||
"./compact-explorer-sidebar-host.tsx",
|
||||
["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"],
|
||||
],
|
||||
] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(new URL(path, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let hostsFound = 0;
|
||||
let webKeysFound = 0;
|
||||
function visit(node: ts.Node) {
|
||||
let name: string | undefined;
|
||||
if (ts.isIdentifier(node)) name = node.text;
|
||||
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
|
||||
if (name && hosts.some((host) => host === name)) {
|
||||
hostsFound++;
|
||||
let webOnly = false;
|
||||
let keyed = false;
|
||||
for (
|
||||
let child = node, parent = node.parent;
|
||||
parent;
|
||||
child = parent, parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isIfStatement(parent) &&
|
||||
parent.expression.getText(source) === "isWeb" &&
|
||||
parent.thenStatement === child
|
||||
)
|
||||
webOnly = true;
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
|
||||
)
|
||||
keyed = true;
|
||||
}
|
||||
if (keyed && webOnly) webKeysFound++;
|
||||
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(hostsFound).toBeGreaterThan(0);
|
||||
expect(webKeysFound).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,15 @@ interface AppearanceStyleBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Remounts its children when an appearance token changes. Memoized and parsed content bakes
|
||||
// tokens in, and on web numeric tokens (font sizes, line heights) are baked into generated
|
||||
// classes that only a re-render refreshes. Native tracked styles update in place.
|
||||
// Keep native gesture hosts outside these keys too: shared gesture refs can outlive detached
|
||||
// dependent views until passive cleanup. It must sit below every native navigator: remounting
|
||||
// a navigator while settings hydrate
|
||||
// detaches its screen container inside a FragmentManager transaction and crashes Android.
|
||||
// `ThemedStack` places one per screen; the app shell places them around the chrome outside the
|
||||
// navigators.
|
||||
function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) {
|
||||
return <Fragment key={appearanceKey}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
|
||||
interface CompactExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
@@ -177,11 +178,16 @@ export function CompactExplorerSidebarHost({
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
) : null;
|
||||
|
||||
let themedExplorer = explorer;
|
||||
if (isWeb) {
|
||||
themedExplorer = <AppearanceStyleBoundary>{explorer}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
if (presentation === "dock") {
|
||||
return (
|
||||
<View style={styles.row} onLayout={handleContainerLayout}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
{explorer}
|
||||
{themedExplorer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +200,7 @@ export function CompactExplorerSidebarHost({
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{explorer}
|
||||
{themedExplorer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
FlatList as SheetFlatList,
|
||||
ScrollView as SheetScrollView,
|
||||
} from "@/components/ui/scroll-view";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -1190,7 +1193,7 @@ function ModelRowList({
|
||||
|
||||
if (isCompact && isNative) {
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
<SheetFlatList
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={header}
|
||||
@@ -1488,7 +1491,7 @@ function ModelBrowserContent({
|
||||
return scrolling === "independent" ? (
|
||||
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
<SheetScrollView
|
||||
style={styles.virtualizedModelList}
|
||||
contentContainerStyle={[
|
||||
styles.virtualizedModelListContent,
|
||||
@@ -1500,7 +1503,7 @@ function ModelBrowserContent({
|
||||
testID="compact-provider-list"
|
||||
>
|
||||
{allProvidersContent}
|
||||
</BottomSheetScrollView>
|
||||
</SheetScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
|
||||
interface SettingsGroupProps {
|
||||
title: string;
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface SettingsSectionProps {
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type {
|
||||
SettingsRowProps,
|
||||
SettingsSwitchProps,
|
||||
SettingsSelectProps,
|
||||
SettingsInputProps,
|
||||
SettingsActionProps,
|
||||
} from "@getpaseo/plugin/client/ui";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormTextInput } from "@/components/ui/form-field";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
|
||||
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
|
||||
return (
|
||||
<View style={settingsStyles.card} testID={testID}>
|
||||
{Children.toArray(children).map((child, index) => (
|
||||
<View
|
||||
key={isValidElement(child) ? child.key : index}
|
||||
style={index ? settingsStyles.rowBorder : undefined}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
|
||||
return (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
<View style={styles.label}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
{hint ? <Text style={settingsStyles.rowHint}>{hint}</Text> : null}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{children ? <View style={styles.control}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) {
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={row.label}
|
||||
/>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsOption<Value extends string>({
|
||||
option,
|
||||
selected,
|
||||
onValueChange,
|
||||
}: {
|
||||
option: { label: string; value: Value };
|
||||
selected: boolean;
|
||||
onValueChange(value: Value): void;
|
||||
}) {
|
||||
const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={select}>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSelect<Value extends string>({
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value>) {
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={row.label}
|
||||
>
|
||||
<Text style={styles.value}>
|
||||
{options.find((option) => option.value === value)?.label ?? value}
|
||||
</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{options.map((option) => (
|
||||
<SettingsOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={option.value === value}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
initialValue,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
disabled,
|
||||
secureTextEntry,
|
||||
ref,
|
||||
...row
|
||||
}: SettingsInputProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const input = useRef<EditingTextInputHandle>(null);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => input.current?.focus(),
|
||||
blur: () => input.current?.blur(),
|
||||
getText: () => input.current?.getText() ?? "",
|
||||
replaceText: (text) => input.current?.replaceText(text),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<FormTextInput
|
||||
ref={input}
|
||||
initialValue={initialValue}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
editable={!disabled}
|
||||
secureTextEntry={secureTextEntry}
|
||||
accessibilityLabel={row.label}
|
||||
size={compact ? "md" : "sm"}
|
||||
style={styles.input}
|
||||
/>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
|
||||
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
|
||||
control: { flexShrink: 1, maxWidth: "100%" },
|
||||
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
input: { minWidth: 180 },
|
||||
}));
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { seedRuntimeWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
setHostProfiles([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const BottomSheetScopeContext = createContext(false);
|
||||
|
||||
export function BottomSheetTextInputScope({ children }: { children: ReactNode }) {
|
||||
export function BottomSheetScope({ children }: { children: ReactNode }) {
|
||||
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
});
|
||||
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
function PortalHostProbe() {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope";
|
||||
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
@@ -65,9 +65,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
<BottomSheetTextInputScope>
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
</BottomSheetTextInputScope>
|
||||
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
|
||||
@@ -68,10 +68,20 @@ describe("computePosition", () => {
|
||||
|
||||
describe("getTransformOrigin", () => {
|
||||
it.each([
|
||||
["bottom", "start", "left top"],
|
||||
["top", "end", "right bottom"],
|
||||
["right", "center", "center center"],
|
||||
] as const)("maps %s/%s to %s", (placement, alignment, expected) => {
|
||||
expect(getTransformOrigin(placement, alignment)).toBe(expected);
|
||||
["bottom", "start", [0, 0, 0]],
|
||||
["bottom", "center", ["50%", 0, 0]],
|
||||
["bottom", "end", ["100%", 0, 0]],
|
||||
["top", "start", [0, "100%", 0]],
|
||||
["top", "center", ["50%", "100%", 0]],
|
||||
["top", "end", ["100%", "100%", 0]],
|
||||
["left", "start", [0, "50%", 0]],
|
||||
["left", "center", ["50%", "50%", 0]],
|
||||
["left", "end", ["100%", "50%", 0]],
|
||||
["right", "start", [0, "50%", 0]],
|
||||
["right", "center", ["50%", "50%", 0]],
|
||||
["right", "end", ["100%", "50%", 0]],
|
||||
] as const)("emits a native-ready origin for %s/%s", (placement, alignment, expected) => {
|
||||
// Theme updates may reach Fabric without React Native's JS string preprocessing.
|
||||
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,18 +144,21 @@ export function computePosition({
|
||||
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
|
||||
}
|
||||
|
||||
export function getTransformOrigin(placement: Placement, alignment: Alignment): string {
|
||||
let vertical: string;
|
||||
if (placement === "bottom") vertical = "top";
|
||||
else if (placement === "top") vertical = "bottom";
|
||||
else vertical = "center";
|
||||
export function getTransformOrigin(
|
||||
placement: Placement,
|
||||
alignment: Alignment,
|
||||
): [number | string, number | string, number] {
|
||||
let vertical: number | string;
|
||||
if (placement === "bottom") vertical = 0;
|
||||
else if (placement === "top") vertical = "100%";
|
||||
else vertical = "50%";
|
||||
|
||||
let horizontal: string;
|
||||
if (alignment === "start") horizontal = "left";
|
||||
else if (alignment === "end") horizontal = "right";
|
||||
else horizontal = "center";
|
||||
let horizontal: number | string;
|
||||
if (alignment === "start") horizontal = 0;
|
||||
else if (alignment === "end") horizontal = "100%";
|
||||
else horizontal = "50%";
|
||||
|
||||
// React Native parses transform-origin positionally (x then y), unlike CSS
|
||||
// which accepts keyword pairs in either order.
|
||||
return `${horizontal} ${vertical}`;
|
||||
// Native theme updates can bypass RN's JS string preprocessing. Supply the native
|
||||
// array shape directly; Reanimated's web style builder also accepts this form.
|
||||
return [horizontal, vertical, 0];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { forwardRef, type Ref } from "react";
|
||||
import {
|
||||
ScrollView as NativeScrollView,
|
||||
FlatList as NativeFlatList,
|
||||
type ScrollViewProps,
|
||||
type FlatListProps,
|
||||
} from "react-native";
|
||||
import { BottomSheetScrollView, BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
ScrollView as GestureScrollView,
|
||||
FlatList as GestureFlatList,
|
||||
type NativeViewGestureHandlerProps,
|
||||
} from "react-native-gesture-handler";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
|
||||
|
||||
// Both libraries forward to the RN FlatList instance. Their published ref types
|
||||
// disagree about getScrollResponder (Gorhom) and include the component function
|
||||
// in the instance intersection (RNGH); keep that type discrepancy at this boundary.
|
||||
const SheetFlatList = BottomSheetFlatList as unknown as typeof NativeFlatList;
|
||||
const HorizontalFlatList = GestureFlatList as unknown as <Item>(
|
||||
props: FlatListProps<Item> & NativeViewGestureHandlerProps & { ref?: Ref<NativeFlatList<Item>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
/** The presentation owns gesture integration; callers keep the React Native API. */
|
||||
export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
|
||||
function ScrollView(props, ref) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
if (!inSheet) return <NativeScrollView {...props} ref={ref} />;
|
||||
if (props.horizontal) {
|
||||
return isNative ? (
|
||||
<GestureScrollView {...props} ref={ref} disallowInterruption />
|
||||
) : (
|
||||
<NativeScrollView {...props} ref={ref} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BottomSheetScrollView {...props} ref={ref}>
|
||||
{props.children}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function SheetAwareFlatList<Item>(props: FlatListProps<Item>, ref: Ref<NativeFlatList<Item>>) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
if (!inSheet) return <NativeFlatList {...props} ref={ref} />;
|
||||
if (props.horizontal) {
|
||||
return isNative ? (
|
||||
<HorizontalFlatList {...props} ref={ref} disallowInterruption />
|
||||
) : (
|
||||
<NativeFlatList {...props} ref={ref} />
|
||||
);
|
||||
}
|
||||
return <SheetFlatList {...props} ref={ref} />;
|
||||
}
|
||||
|
||||
// forwardRef erases the item generic. All three implementations expose the RN list methods.
|
||||
export const FlatList = forwardRef(SheetAwareFlatList) as <Item>(
|
||||
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
|
||||
) => React.ReactElement;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Web dialogs use ordinary scrolling at every breakpoint. Keep native gesture
|
||||
// dependencies out of this module's import graph.
|
||||
export { ScrollView, FlatList } from "react-native";
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
import { ScrollView } from "./scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -87,7 +81,7 @@ export function ScrollableCodeSurface({
|
||||
[maxHeight],
|
||||
);
|
||||
const outerScrollStyle = useMemo(
|
||||
() => [maxHeightStyle, scrollStyle],
|
||||
() => [styles.scroll, maxHeightStyle, scrollStyle],
|
||||
[maxHeightStyle, scrollStyle],
|
||||
);
|
||||
const contentStyle = useMemo(
|
||||
@@ -108,24 +102,21 @@ export function ScrollableCodeSurface({
|
||||
<SurfaceCard
|
||||
tone={tone}
|
||||
bordered={bordered}
|
||||
style={style}
|
||||
style={[styles.scroll, style]}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
codeSurface
|
||||
>
|
||||
<ScrollView
|
||||
style={outerScrollStyle}
|
||||
contentContainerStyle={contentStyle}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{horizontal ? (
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
{codeContent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
codeContent
|
||||
)}
|
||||
<ScrollView style={outerScrollStyle} nestedScrollEnabled showsVerticalScrollIndicator>
|
||||
<View style={contentStyle}>
|
||||
{horizontal ? (
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
{codeContent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
codeContent
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SurfaceCard>
|
||||
);
|
||||
@@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { TextInput as NativeTextInput, type TextInputProps } from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsInsideBottomSheet } from "../bottom-sheet-scope";
|
||||
|
||||
/** Registers focus with the sheet so the keyboard can move the active input into view. */
|
||||
export const SheetAwareTextInput = forwardRef<NativeTextInput, TextInputProps>(
|
||||
function SheetAwareTextInput(props, ref) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
const setRef = useCallback(
|
||||
(input: NativeTextInput | null | undefined) => {
|
||||
if (typeof ref === "function") ref(input ?? null);
|
||||
else if (ref) ref.current = input ?? null;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
return inSheet && isNative ? (
|
||||
<BottomSheetTextInput {...props} ref={setRef} />
|
||||
) : (
|
||||
<NativeTextInput {...props} ref={setRef} />
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act, createRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BottomSheetTextInputScope } from "./bottom-sheet-scope";
|
||||
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
|
||||
import { EditingTextInput } from "./text-input.native";
|
||||
import type { EditingTextInputHandle } from "./types";
|
||||
|
||||
@@ -62,9 +62,9 @@ describe("EditingTextInputNative", () => {
|
||||
root?.render(
|
||||
<>
|
||||
<EditingTextInput testID="outside" />
|
||||
<BottomSheetTextInputScope>
|
||||
<BottomSheetScope>
|
||||
<EditingTextInput testID="inside" />
|
||||
</BottomSheetTextInputScope>
|
||||
</BottomSheetScope>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("EditingTextInputNative", () => {
|
||||
expect(handleRef.current?.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("replaces the native input when clearing text", () => {
|
||||
it("replaces the native input when resetting the editor", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
@@ -107,13 +107,13 @@ describe("EditingTextInputNative", () => {
|
||||
const grownInput = container?.querySelector("input");
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
handleRef.current?.reset();
|
||||
});
|
||||
|
||||
expect(container?.querySelector("input")).not.toBe(grownInput);
|
||||
});
|
||||
|
||||
it("restores focus after replacing a cleared native input", () => {
|
||||
it("restores focus after replacing a reset native input", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
@@ -125,13 +125,13 @@ describe("EditingTextInputNative", () => {
|
||||
originalInput.focus();
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
handleRef.current?.reset();
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(container?.querySelector("input"));
|
||||
});
|
||||
|
||||
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
|
||||
it("focuses the replacement input when focus is requested before an editor reset remounts", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
@@ -142,7 +142,7 @@ describe("EditingTextInputNative", () => {
|
||||
const originalFocus = vi.spyOn(originalInput, "focus");
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
handleRef.current?.reset();
|
||||
handleRef.current?.focus();
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("EditingTextInputNative", () => {
|
||||
expect(document.activeElement).toBe(replacementInput);
|
||||
});
|
||||
|
||||
it("drops a pending focus restore when blur is requested before a cleared input remounts", () => {
|
||||
it("drops a pending focus restore when blur is requested before an editor reset remounts", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
@@ -164,7 +164,7 @@ describe("EditingTextInputNative", () => {
|
||||
originalInput.focus();
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
handleRef.current?.reset();
|
||||
handleRef.current?.blur();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import PasteInput, {
|
||||
type PastedFile,
|
||||
type PasteTextInputInstance,
|
||||
} from "@mattermost/react-native-paste-input";
|
||||
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
|
||||
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
|
||||
type NativeInput = (TextInput | PasteTextInputInstance) & {
|
||||
@@ -35,7 +35,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
const inputRef = useRef<NativeInput | null>(null);
|
||||
const initialTextRef = useRef(initialValue);
|
||||
const textRef = useRef(initialTextRef.current);
|
||||
// Clearing swaps the native input for a fresh instance (see replaceText).
|
||||
// Resetting the editor swaps the native input to reset intrinsic multiline
|
||||
// sizing. Text replacement (including an empty buffer) keeps the input mounted.
|
||||
// Until React commits that swap, `inputRef` still points at the doomed
|
||||
// instance: focusing it asks Android for the keyboard and then tears the
|
||||
// focused view down, which cancels the show. Fabric also runs view
|
||||
@@ -74,27 +75,31 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
if (nextText === "") {
|
||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
} else {
|
||||
inputRef.current?.clear?.();
|
||||
}
|
||||
isAwaitingReplacementRef.current = true;
|
||||
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
||||
return;
|
||||
}
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
return;
|
||||
}
|
||||
if (nextText === "") {
|
||||
inputRef.current?.clear?.();
|
||||
return;
|
||||
}
|
||||
inputRef.current?.setNativeProps?.({
|
||||
text: nextText,
|
||||
...(selection ? { selection } : {}),
|
||||
});
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText("");
|
||||
} else {
|
||||
inputRef.current?.clear?.();
|
||||
}
|
||||
isAwaitingReplacementRef.current = true;
|
||||
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
||||
},
|
||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||
}));
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (input && "value" in input) input.value = "";
|
||||
},
|
||||
getNativeRef: () => inputRef.current,
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface EditingTextInputHandle {
|
||||
isFocused(): boolean;
|
||||
getText(): string;
|
||||
replaceText(text: string, selection?: { start: number; end: number }): void;
|
||||
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
|
||||
reset(): void;
|
||||
getNativeRef(): unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -12,7 +13,11 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useProjectIcon } from "@/projects/icons";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
@@ -61,25 +66,18 @@ function resolveWorkspaceTitle({
|
||||
|
||||
function buildChatDraftComposerArgs({
|
||||
serverId,
|
||||
isConnected,
|
||||
workspaceDirectory,
|
||||
sourceDirectory,
|
||||
pendingWorkspaceSetup,
|
||||
}: {
|
||||
serverId: string;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | undefined;
|
||||
sourceDirectory: string;
|
||||
pendingWorkspaceSetup: { creationMethod: string } | null;
|
||||
}) {
|
||||
return {
|
||||
initialServerId: serverId || null,
|
||||
initialValues:
|
||||
workspaceDirectory || sourceDirectory
|
||||
? { workingDir: workspaceDirectory || sourceDirectory }
|
||||
: undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
||||
};
|
||||
}
|
||||
@@ -164,9 +162,13 @@ export function WorkspaceSetupDialog() {
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const mergeWorkspaces = useCallback(
|
||||
(targetServerId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -186,7 +188,6 @@ export function WorkspaceSetupDialog() {
|
||||
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
||||
composer: buildChatDraftComposerArgs({
|
||||
serverId,
|
||||
isConnected,
|
||||
workspaceDirectory: workspace?.workspaceDirectory,
|
||||
sourceDirectory,
|
||||
pendingWorkspaceSetup,
|
||||
@@ -341,17 +342,13 @@ export function WorkspaceSetupDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(
|
||||
agent.id,
|
||||
applyLegacyDaemonWorkspaceOwnership({
|
||||
serverId,
|
||||
agent: normalizeAgentSnapshot(agent, serverId),
|
||||
}),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
getHostRuntimeStore().acceptAgentSnapshot(
|
||||
serverId,
|
||||
applyLegacyDaemonWorkspaceOwnership({
|
||||
serverId,
|
||||
agent: normalizeAgentSnapshot(agent, serverId),
|
||||
}),
|
||||
);
|
||||
navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
@@ -368,7 +365,6 @@ export function WorkspaceSetupDialog() {
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
t,
|
||||
toast,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { seedRuntimeWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
@@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile()]);
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
seedSessionWorkspaces(
|
||||
seedRuntimeWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||
@@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
seedSessionWorkspaces(
|
||||
seedRuntimeWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
[
|
||||
@@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||
seedSessionWorkspaces(
|
||||
seedRuntimeWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
seedSessionWorkspaces(
|
||||
seedRuntimeWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -34,8 +34,6 @@ vi.mock("@/attachments/service", () => ({
|
||||
vi.mock("@/hooks/use-agent-form-state", () => ({
|
||||
useAgentFormState: () => ({
|
||||
selectedServerId: "host-1",
|
||||
setSelectedServerId: () => undefined,
|
||||
setSelectedServerIdFromUser: () => undefined,
|
||||
selectedProvider: "codex",
|
||||
setProviderFromUser: () => undefined,
|
||||
selectedMode: "auto",
|
||||
@@ -45,8 +43,6 @@ vi.mock("@/hooks/use-agent-form-state", () => ({
|
||||
selectedThinkingOptionId: "",
|
||||
setThinkingOptionFromUser: () => undefined,
|
||||
workingDir: "/repo",
|
||||
setWorkingDir: () => undefined,
|
||||
setWorkingDirFromUser: () => undefined,
|
||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
||||
providerDefinitionMap: new Map(),
|
||||
agentDefinition: undefined,
|
||||
@@ -116,26 +112,24 @@ vi.mock("@/hooks/use-agent-form-state", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountedRoots = new Set<Root>();
|
||||
function createTestRoot(container: HTMLElement): Root {
|
||||
const root = createRoot(container);
|
||||
mountedRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots) root.unmount();
|
||||
mountedRoots.clear();
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
@@ -147,18 +141,8 @@ beforeAll(async () => {
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
document.body.innerHTML = "<div id='root'></div>";
|
||||
localStorage.clear();
|
||||
|
||||
useDraftStore.setState({
|
||||
drafts: {},
|
||||
@@ -191,9 +175,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
draftKey,
|
||||
composer: {
|
||||
initialServerId: "host-1",
|
||||
initialValues: { workingDir: "/repo" },
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
lockedWorkingDir: "/repo",
|
||||
},
|
||||
});
|
||||
@@ -206,7 +188,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
let root: Root | null = createRoot(container);
|
||||
let root: Root | null = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -248,7 +230,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
root!.unmount();
|
||||
});
|
||||
|
||||
root = createRoot(container);
|
||||
root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -306,7 +288,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -372,7 +354,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -423,7 +405,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -438,9 +420,16 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
||||
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
|
||||
text: "with attachment",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input;
|
||||
await act(async () => {
|
||||
// Web text publication occurs after paint; attachments save immediately.
|
||||
await expect.poll(readPersistedInput).toEqual({
|
||||
text: "with attachment",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,7 +494,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -559,7 +548,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -35,7 +35,6 @@ interface AgentInputDraftComposerOptions {
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
|
||||
@@ -68,12 +67,13 @@ export interface AgentInputDraft {
|
||||
|
||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||
const composerOptions = input.composer ?? null;
|
||||
const workingDir = composerOptions?.lockedWorkingDir?.trim() || "";
|
||||
const formState = useAgentFormState({
|
||||
initialServerId: composerOptions?.initialServerId ?? null,
|
||||
workingDir,
|
||||
serverId: composerOptions?.initialServerId ?? null,
|
||||
initialValues: composerOptions?.initialValues,
|
||||
isVisible: composerOptions?.isVisible ?? false,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
||||
});
|
||||
const draftKey = useMemo(
|
||||
() =>
|
||||
@@ -214,17 +214,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
};
|
||||
}, [draftKey, publishTextReplacement]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
if (formState.workingDir.trim() === lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const providerSelection = useMemo<ProviderSelectionState>(
|
||||
() => ({
|
||||
provider: formState.selectedProvider,
|
||||
@@ -254,7 +243,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[effectiveModelId, providerSelection],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
const {
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
||||
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -55,7 +55,6 @@ import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -248,7 +247,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
id: tabId,
|
||||
provider,
|
||||
status: "running",
|
||||
activeTurn: null,
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
@@ -270,17 +269,10 @@ function buildDraftAgentSnapshot(input: {
|
||||
}
|
||||
|
||||
function buildDraftInitialValues(input: {
|
||||
workingDir: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): CreateAgentInitialValues | undefined {
|
||||
if (!input.workingDir) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.initialSetup) {
|
||||
return { workingDir: input.workingDir };
|
||||
}
|
||||
if (!input.initialSetup) return undefined;
|
||||
return {
|
||||
workingDir: input.workingDir,
|
||||
provider: input.initialSetup.provider,
|
||||
modeId: input.initialSetup.modeId,
|
||||
model: input.initialSetup.model,
|
||||
@@ -298,13 +290,6 @@ function resolveDraftWorkingDirectory(input: {
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] {
|
||||
if (!input.isConnected) {
|
||||
return EMPTY_ONLINE_SERVER_IDS;
|
||||
}
|
||||
return [input.serverId];
|
||||
}
|
||||
|
||||
interface WorkspaceDraftAgentTabProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -341,7 +326,6 @@ export function WorkspaceDraftAgentTab({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
id: w.id,
|
||||
@@ -353,10 +337,8 @@ export function WorkspaceDraftAgentTab({
|
||||
initialSetup: draftSetup,
|
||||
});
|
||||
const draftInitialValues = buildDraftInitialValues({
|
||||
workingDir: draftWorkingDirectory,
|
||||
initialSetup: draftSetup,
|
||||
});
|
||||
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
buildDraftStoreKey({
|
||||
@@ -373,7 +355,6 @@ export function WorkspaceDraftAgentTab({
|
||||
initialValues: draftInitialValues,
|
||||
initialFeatureValues: draftSetup?.featureValues,
|
||||
isVisible: true,
|
||||
onlineServerIds,
|
||||
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
@@ -1487,8 +1488,10 @@ function ComposerContentImpl({
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn
|
||||
?.turnId ?? undefined)
|
||||
? (selectAgentTurnPresentation(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
targetAgentId,
|
||||
).turnId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
@@ -1505,8 +1508,6 @@ function ComposerContentImpl({
|
||||
const isCancellingAgent = useSessionStore(
|
||||
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling,
|
||||
);
|
||||
const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation);
|
||||
const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation);
|
||||
const isAgentRunning = hasActiveTurn;
|
||||
// Queueing behind a permission prompt would strand the message: the turn is
|
||||
// parked until the request is answered.
|
||||
@@ -1812,7 +1813,7 @@ function ComposerContentImpl({
|
||||
isConnected,
|
||||
});
|
||||
if (!cancellation) return;
|
||||
const requestId = beginAgentCancellation(serverId, targetAgentId);
|
||||
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
|
||||
void cancellation
|
||||
.catch((error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
@@ -1821,18 +1822,10 @@ function ComposerContentImpl({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
settleAgentCancellation(serverId, targetAgentId, requestId);
|
||||
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
|
||||
});
|
||||
messageInputRef.current?.focus();
|
||||
}, [
|
||||
beginAgentCancellation,
|
||||
client,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
serverId,
|
||||
settleAgentCancellation,
|
||||
]);
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
||||
|
||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
||||
|
||||
@@ -1240,7 +1240,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
valueRef.current = nextText;
|
||||
updateLiveTextPresence(nextText);
|
||||
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
|
||||
textInputRef.current?.replaceText(nextText, selection);
|
||||
if (nextText === "") {
|
||||
textInputRef.current?.reset();
|
||||
} else {
|
||||
textInputRef.current?.replaceText(nextText, selection);
|
||||
}
|
||||
onChangeText(nextText);
|
||||
},
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
@@ -1292,7 +1296,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
|
||||
valueRef.current = textReplacement.text;
|
||||
updateLiveTextPresence(textReplacement.text);
|
||||
textInputRef.current?.replaceText(textReplacement.text);
|
||||
if (textReplacement.text === "") {
|
||||
textInputRef.current?.reset();
|
||||
} else {
|
||||
textInputRef.current?.replaceText(textReplacement.text);
|
||||
}
|
||||
}, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -82,6 +82,20 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ComposerTextInput web IME composition", () => {
|
||||
it("resets the composer text while preserving focus", () => {
|
||||
const mounted = mountInput(ignoreTextChange);
|
||||
act(() => {
|
||||
mounted.inputRef.current?.focus();
|
||||
typeFromIme(mounted.textarea, "line one\nline two");
|
||||
});
|
||||
|
||||
act(() => mounted.inputRef.current?.reset());
|
||||
|
||||
expect(mounted.textarea.value).toBe("");
|
||||
expect(mounted.inputRef.current?.getText()).toBe("");
|
||||
expect(document.activeElement).toBe(mounted.textarea);
|
||||
});
|
||||
|
||||
it("keeps locally typed text when its parent rerenders with a stale value", () => {
|
||||
const recorder = createTextRecorder();
|
||||
const mounted = mountInput(recorder.onChangeText);
|
||||
|
||||
@@ -231,8 +231,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness);
|
||||
const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
@@ -412,15 +410,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
client.subscribeConnectionStatus((connection) => {
|
||||
if (connection.status === "connected") return;
|
||||
clearAgentTurnLiveness(serverId);
|
||||
}),
|
||||
[clearAgentTurnLiveness, client, serverId],
|
||||
);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||
@@ -544,7 +533,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
if (turnLiveness.length > 0) {
|
||||
applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
}
|
||||
owner.enqueueStreamEvent(agentId, {
|
||||
event: streamEvent,
|
||||
@@ -797,7 +786,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setIsPlayingAudio,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
applyAgentTurnLiveness,
|
||||
clearAgentStreamHead,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user