mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d96ee1522 | ||
|
|
aa50151fa1 | ||
|
|
bf4979fd62 | ||
|
|
7b7685678d | ||
|
|
5715b5ba62 | ||
|
|
9952c55983 | ||
|
|
e9e479c6ba | ||
|
|
2553ab4d82 | ||
|
|
ad56659a89 | ||
|
|
b0c9de0c90 | ||
|
|
8cccc1f6fa | ||
|
|
3a9304607c | ||
|
|
b1a3b0772d | ||
|
|
5599f9e567 | ||
|
|
4e9a4582b7 | ||
|
|
3fea128382 | ||
|
|
53ee9cd993 | ||
|
|
767b0e970b | ||
|
|
d3c902e9fa | ||
|
|
60aff2346c | ||
|
|
5de45e2086 | ||
|
|
5764863f63 | ||
|
|
4b45bc9fc1 | ||
|
|
a8f5d969b8 | ||
|
|
23c4404b95 | ||
|
|
2838922ade | ||
|
+1 |
264f6e1cca | ||
|
|
cc744cc669 | ||
|
|
aff476b01b | ||
|
|
d596861891 | ||
|
|
be2fb1cab9 | ||
|
|
87818812ad | ||
|
|
b9ac1d4982 | ||
|
|
d91171954b | ||
|
|
f232d23e9a | ||
|
|
68e722ec2e | ||
|
|
3b41183606 | ||
|
|
690f5298cb | ||
|
|
68938b668e | ||
|
|
560e478ef5 | ||
|
|
4965af219f | ||
|
|
940dbfd242 | ||
|
|
4340607093 | ||
|
|
849a876bce | ||
|
|
f4ba16a0b3 | ||
|
|
9d010211a2 | ||
|
|
8b5201fedf | ||
|
|
05874e289a | ||
|
|
d0a30ed4da | ||
|
|
cf4509631b | ||
|
|
b85b44aea1 | ||
|
|
7bb7d4ed08 | ||
|
|
dd5df9ec61 | ||
|
|
8de52a3c99 | ||
|
|
dffde6ae8b | ||
|
|
a43c8d888f | ||
|
|
792715e762 | ||
|
|
827178df90 | ||
|
|
c54f20e53e | ||
|
|
c7e7e52b0c |
@@ -34,6 +34,7 @@ hub:
|
||||
- "packages/server/src/server/hub/**"
|
||||
|
||||
server:
|
||||
- "plugins/**"
|
||||
- "packages/server/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
|
||||
@@ -117,9 +117,15 @@ jobs:
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Verify built-in plugins from built output
|
||||
run: node --test scripts/builtin-plugins-dist.test.mjs
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run website tests
|
||||
run: npx vitest run packages/website/src --bail=1
|
||||
|
||||
- name: Install website test browsers
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
@@ -169,6 +175,10 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.2 - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
|
||||
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
|
||||
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
|
||||
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
|
||||
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
|
||||
Only the Claude provider does this today; the others still report a death only when a turn happens to
|
||||
be in flight.
|
||||
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
|
||||
the unfinished turn is lost.
|
||||
|
||||
### Cancellation
|
||||
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Terminology:
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
|
||||
+5
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
@@ -31,6 +31,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+125
-13
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
|
||||
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
|
||||
Changing that root field is
|
||||
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
|
||||
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.
|
||||
@@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
|
||||
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
|
||||
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
|
||||
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
|
||||
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
|
||||
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
@@ -249,12 +259,23 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="MessageCircle"
|
||||
active={currentScreen?.screenId === "main"}
|
||||
onPress={() => openScreen({ screenId: "main" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Greeting);
|
||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
|
||||
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -267,15 +288,33 @@ errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
|
||||
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
|
||||
that id, so links saved before a plugin migrated keep working.
|
||||
|
||||
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
|
||||
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
|
||||
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
|
||||
keys are stored under a `param.` prefix; plugins never see it and may use any key.
|
||||
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
||||
params.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
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
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
||||
|
||||
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
|
||||
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
|
||||
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
|
||||
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
|
||||
disposed per mounted surface. See the
|
||||
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
|
||||
changes both `usePaseo()` and `useRpc()` to that host's installation. 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.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
@@ -311,7 +350,7 @@ existing agent-context instances, but it cannot create an agent panel without an
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
|
||||
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
@@ -342,9 +381,38 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
|
||||
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
|
||||
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
|
||||
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
|
||||
environment. Spawn with those values; do not merge the plugin process's environment back in.
|
||||
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
|
||||
Without a declared command, existing providers keep their connection behavior and receive no launch.
|
||||
|
||||
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
|
||||
that effective launch. Return `{ available: true }` or
|
||||
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
|
||||
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
|
||||
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
|
||||
settles (including disabled plugins), then on each installed registry generation.
|
||||
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
|
||||
remain valid for plugin registration but cannot be used as config provider IDs.
|
||||
|
||||
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
|
||||
connection receives the new launch without restarting the daemon. Running sessions keep their
|
||||
current launch until refreshed, as native providers do.
|
||||
|
||||
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
|
||||
workspace target plus the resolved `launch` when a command is declared. 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).
|
||||
|
||||
@@ -355,12 +423,15 @@ Provider settings are toggle/select data that Paseo renders in the composer. Kee
|
||||
the opaque `providerOptions` config object.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@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`
|
||||
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
|
||||
only the supplied per-session env for sessions. The direct and ACP examples live in
|
||||
`plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
@@ -376,6 +447,47 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
Sidebar header and footer items are plugin components, not descriptors. The
|
||||
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
|
||||
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
|
||||
one section's order from built-ins, plugin groups, and the section's preference
|
||||
(`sidebarNavItems` or `sidebarFooterItems`).
|
||||
|
||||
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
|
||||
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
|
||||
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
|
||||
button.
|
||||
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
|
||||
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
|
||||
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
|
||||
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
|
||||
the responder system, so the row's own press anchors too. Until a press, the first mounted row
|
||||
holds the anchor. An item that renders no kit component
|
||||
anchors to its section container.
|
||||
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
|
||||
the line runs edge to edge in both sections.
|
||||
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
|
||||
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
|
||||
row; a button inside it wins its own press because only the innermost pressable responds.
|
||||
- A new-API item renders from the current route's host, else the host remembered under
|
||||
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
|
||||
set. One key per plugin: switching host on any of its screens moves all its items.
|
||||
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
|
||||
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
|
||||
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
|
||||
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
@@ -445,7 +557,7 @@ be rendered intact. The daemon advertises this RPC through
|
||||
|
||||
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
|
||||
+27
-18
@@ -1,6 +1,22 @@
|
||||
# Adding a New Provider to Paseo
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
|
||||
server integrations.
|
||||
|
||||
## Plugin providers
|
||||
|
||||
Keep a bundled provider in `plugins/<id>/` and register it through
|
||||
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
|
||||
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
|
||||
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
|
||||
|
||||
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
|
||||
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
|
||||
connecting. Register the provider's icon with the plugin rather than adding it to the app's
|
||||
provider icon map. You do not need a core manifest entry or provider factory.
|
||||
|
||||
For Antigravity installation, modes, and limitations, see
|
||||
[Supported providers](../public-docs/supported-providers.md#antigravity).
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
@@ -46,7 +62,7 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
|
||||
## Two Integration Patterns
|
||||
## Core adapter patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
@@ -99,6 +115,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
@@ -195,26 +213,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
## Usage sources
|
||||
|
||||
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
|
||||
|
||||
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
- `providerId`, `displayName`, `status`, and optional `planLabel`
|
||||
- any number of `windows` such as Session, Weekly, or Biweekly
|
||||
- optional `balances` for credits, USD, requests, or tokens
|
||||
- optional `details` for provider-specific rows
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
### Credentials are read only
|
||||
|
||||
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
|
||||
|
||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||
|
||||
### Usage fetchers are read-only on credentials
|
||||
|
||||
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
|
||||
---
|
||||
|
||||
@@ -302,7 +311,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
### 2. Add to the provider manifest
|
||||
|
||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
|
||||
First, define the modes with visual metadata:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
"plugins": {
|
||||
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
|
||||
"project": ["**/*.{ts,tsx}"]
|
||||
},
|
||||
".": {
|
||||
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
|
||||
Generated
+44
@@ -10,6 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -6333,6 +6334,10 @@
|
||||
"resolved": "packages/app",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/builtin-plugins": {
|
||||
"resolved": "plugins",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/cli": {
|
||||
"resolved": "packages/cli",
|
||||
"link": true
|
||||
@@ -21268,6 +21273,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-screen-orientation": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz",
|
||||
"integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||
@@ -36468,6 +36483,7 @@
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-screen-orientation": "~9.0.9",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
@@ -39238,6 +39254,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -40096,6 +40113,21 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
@@ -41712,6 +41744,18 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -66,7 +67,7 @@
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
@@ -100,7 +101,8 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
// Android chooses its full window before the runtime phone/fold/tablet policy applies.
|
||||
orientation: "default",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
@@ -108,6 +110,12 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
infoPlist: {
|
||||
// Keep the existing iPhone restriction while the iPad orientation key
|
||||
// generated by Expo continues to permit rotation.
|
||||
UISupportedInterfaceOrientations: [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
],
|
||||
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
@@ -142,6 +150,7 @@ export default {
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidFoldConfiguration,
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-relative-time-",
|
||||
title: "Relative time agent",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
|
||||
await resetSeededPageState(page);
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await openSessions(page);
|
||||
|
||||
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
hoverChatOutlinePrompt,
|
||||
chatOutlineRail,
|
||||
movePointerOffChatOutline,
|
||||
observePromptIndexRequests,
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
|
||||
const promptIndex = await observePromptIndexRequests(page);
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start an agent with an outline.");
|
||||
await creation.expectPromptVisible();
|
||||
|
||||
const agentIds = await creation.agentIds();
|
||||
expect(agentIds).toHaveLength(1);
|
||||
await promptIndex.waitForRequestFor(agentIds[0]!);
|
||||
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
|
||||
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
|
||||
// The real daemon discovers both through the same Codex model/list boundary.
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
|
||||
},
|
||||
claude: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function openSpeedSelector(page: Page): Promise<void> {
|
||||
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
||||
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
||||
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
|
||||
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (!(await speed.isVisible())) {
|
||||
if (await features.isVisible()) await features.click();
|
||||
else await modelSettings.click();
|
||||
}
|
||||
await expect(speed).toBeVisible({ timeout: 30_000 });
|
||||
await speed.click();
|
||||
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectSpeed(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: label, exact: true }).last().click();
|
||||
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
|
||||
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
|
||||
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
|
||||
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
|
||||
else await expect(sheetTrigger).toContainText(label);
|
||||
}
|
||||
|
||||
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
|
||||
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
||||
const option = page.getByText(choice, { exact: true }).last();
|
||||
if (choices.includes(choice)) {
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
} else await expect(option).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
|
||||
>({
|
||||
clientIdPrefix: "codex-speed-model",
|
||||
});
|
||||
try {
|
||||
await client.setAgentModel(agentId, modelId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 850 },
|
||||
{ name: "compact", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(viewport);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Speed settings",
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
initialPrompt: "Get ready to work on this project.",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
if (viewport.name === "desktop") {
|
||||
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(trigger).toHaveText("");
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
|
||||
}
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||
await selectSpeed(page, "Ultrafast");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||
});
|
||||
await selectSpeed(page, "Normal");
|
||||
if (viewport.name === "desktop") {
|
||||
await switchAgentModel(agent.id, "gpt-6-sol");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -87,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
|
||||
const committedAt = new Date();
|
||||
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
|
||||
await page.clock.install({ time: committedAt });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await page.getByRole("button", { name: /Commits/i }).click();
|
||||
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
});
|
||||
|
||||
async function createFeatureCommit(
|
||||
repoPath: string,
|
||||
authorDate = "2020-01-15T12:00:00Z",
|
||||
): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_AUTHOR_DATE: authorDate,
|
||||
GIT_COMMITTER_DATE: authorDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -338,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function recordCommandRequestsByAgentId(page: Page): string[] {
|
||||
const requests: string[] = [];
|
||||
const record = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
|
||||
if (!payload.includes("draftConfig")) requests.push(payload);
|
||||
};
|
||||
page.on("websocket", (socket) => socket.on("framesent", record));
|
||||
return requests;
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
@@ -359,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
|
||||
|
||||
await page.goto("/new");
|
||||
await expectNewWorkspaceProjectSelected(page, "Choose project");
|
||||
await composerLocator(page).fill("/");
|
||||
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
|
||||
await expect(popover).not.toContainText("/clear");
|
||||
await expect(popover).not.toContainText("/exit");
|
||||
expect(commandRequestsByAgentId).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectContentColumnWidth,
|
||||
expectCustomContentWidthSetting,
|
||||
expectDefaultContentWidthSetting,
|
||||
openAppearanceSettings,
|
||||
resetContentWidth,
|
||||
setContentWidth,
|
||||
} from "../support/helpers/content-width";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
|
||||
|
||||
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "content-width-",
|
||||
title: "Content width",
|
||||
initialPrompt: "Write a long paragraph.",
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
|
||||
const assistantMessage = page.getByTestId("assistant-message").first();
|
||||
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
|
||||
|
||||
await test.step("chat starts at the default width", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { max: 820 });
|
||||
});
|
||||
|
||||
await test.step("settings show the default without a reset", async () => {
|
||||
await openAppearanceSettings(page);
|
||||
await expectDefaultContentWidthSetting(page, 820);
|
||||
});
|
||||
|
||||
await test.step("a custom width is saved and offers a reset", async () => {
|
||||
await setContentWidth(page, 1600);
|
||||
await expectCustomContentWidthSetting(page, 1600);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
|
||||
});
|
||||
|
||||
await test.step("chat and Markdown files use the custom width", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
|
||||
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
|
||||
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
|
||||
});
|
||||
|
||||
await test.step("reset returns to the default without storing it", async () => {
|
||||
await openAppearanceSettings(page);
|
||||
await resetContentWidth(page);
|
||||
await expectDefaultContentWidthSetting(page, 820);
|
||||
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { max: 820 });
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
const ring = meter.locator("svg");
|
||||
const progressColor = await ring
|
||||
.locator("circle")
|
||||
.last()
|
||||
.evaluate((circle) => getComputedStyle(circle).stroke);
|
||||
const screenshot = await ring.screenshot();
|
||||
return ring.evaluate(
|
||||
async (_svg, { png, color }) => {
|
||||
const image = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${png}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(image.width, image.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, image.width, image.height);
|
||||
const [r, g, b] = color.match(/\d+/g)!.map(Number);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const i = (y * image.width + x) * 4;
|
||||
const distance =
|
||||
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
|
||||
if (distance < 40) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new Error(`No pixels painted in the progress colour ${color}`);
|
||||
}
|
||||
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
|
||||
},
|
||||
{ png: screenshot.toString("base64"), color: progressColor },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("context window meter", () => {
|
||||
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-meter-",
|
||||
title: "Context window meter e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
startRestartableHostDaemon,
|
||||
type RestartableHostDaemon,
|
||||
} from "../support/helpers/versioned-host-daemon";
|
||||
|
||||
const PREVIOUS_VERSION = "0.8.0";
|
||||
const UPDATED_VERSION = "0.9.1";
|
||||
const HOST_LABEL = "Version restart QA";
|
||||
const NO_RELOAD_MARKER = "before-restart";
|
||||
|
||||
interface ReloadMarkerWindow {
|
||||
__paseoE2eHostVersionMarker?: string;
|
||||
}
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
let hostDaemon: RestartableHostDaemon | null = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await hostDaemon?.dispose();
|
||||
hostDaemon = null;
|
||||
});
|
||||
|
||||
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
|
||||
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
|
||||
hostDaemon = host;
|
||||
const otherHostVersion =
|
||||
await test.step("the connected host reports its current daemon version", async () => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: host.serverId,
|
||||
label: HOST_LABEL,
|
||||
port: host.port,
|
||||
});
|
||||
const version = await readHelpHostVersion(page, getServerId());
|
||||
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
|
||||
await openHostPage(page, host);
|
||||
await expect(hostIdentity(page)).toContainText("Online");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
|
||||
return version;
|
||||
});
|
||||
|
||||
await test.step("the last known version remains on the host page when the host stops", async () => {
|
||||
await host.stop();
|
||||
await expect(hostIdentity(page)).toContainText("Error");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the host daemon restarts on the new version", async () => {
|
||||
await host.restartWithVersion(UPDATED_VERSION);
|
||||
});
|
||||
|
||||
await test.step("the host page badge follows the new version", async () => {
|
||||
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
|
||||
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
|
||||
await expectNoReloadSinceMarker(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
|
||||
await expect(row).toBeVisible();
|
||||
const text = await row.innerText();
|
||||
await page.keyboard.press("Escape");
|
||||
return text;
|
||||
}
|
||||
|
||||
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
|
||||
const text = await readHelpHostVersion(page, serverId);
|
||||
expect(text).toContain(`v${version}`);
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "host");
|
||||
}
|
||||
|
||||
function hostIdentity(page: Page) {
|
||||
return page.getByTestId("host-page-identity");
|
||||
}
|
||||
|
||||
function hostVersionBadge(page: Page, version: string) {
|
||||
return hostIdentity(page).getByText(`v${version}`, { exact: true });
|
||||
}
|
||||
|
||||
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
|
||||
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
|
||||
// captured Node-side constant is not defined there.
|
||||
await page.evaluate((value) => {
|
||||
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
|
||||
}, marker);
|
||||
}
|
||||
|
||||
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
|
||||
const marker = await page.evaluate(
|
||||
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
|
||||
);
|
||||
expect(marker).toBe(NO_RELOAD_MARKER);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { test, type Page } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -49,6 +49,7 @@ interface ImportFlowScenario {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
importSessionTimestamp: number;
|
||||
repoCleanup(): Promise<void>;
|
||||
unrelatedCleanup(): Promise<void>;
|
||||
}
|
||||
@@ -94,7 +95,7 @@ test.beforeAll(async () => {
|
||||
};
|
||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||
const importSessionId = "fixture-custom-title";
|
||||
await seedClaudeSessions({
|
||||
const importSessionTimestamp = await seedClaudeSessions({
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
@@ -108,6 +109,7 @@ test.beforeAll(async () => {
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
importSessionTimestamp,
|
||||
repoCleanup: repo.cleanup,
|
||||
unrelatedCleanup: unrelated.cleanup,
|
||||
};
|
||||
@@ -122,6 +124,19 @@ test.afterAll(async () => {
|
||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("an open Import Session row keeps its age current", async ({ page }) => {
|
||||
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
await flow.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
await expect(row).toContainText("1m ago");
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("4m ago");
|
||||
});
|
||||
|
||||
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
@@ -225,7 +240,7 @@ async function seedClaudeSessions(input: {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<number> {
|
||||
const sessions = [
|
||||
{
|
||||
cwd: input.projectRoot,
|
||||
@@ -290,4 +305,5 @@ async function seedClaudeSessions(input: {
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
|
||||
const guessed: TerminalProfile = {
|
||||
id: "e2e-cursor-guessed",
|
||||
name: "Cursor guessed",
|
||||
command: "cursor-agent",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const explicit: TerminalProfile = {
|
||||
...guessed,
|
||||
id: "e2e-cursor-explicit",
|
||||
name: "Cursor explicit",
|
||||
icon: "cursor",
|
||||
};
|
||||
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
const menuIconPath = (name: string) =>
|
||||
menu
|
||||
.getByRole("menuitem", { name })
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedMenuPath = await menuIconPath(guessed.name);
|
||||
const explicitMenuPath = await menuIconPath(explicit.name);
|
||||
|
||||
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
|
||||
const settingsIconPath = (id: string) =>
|
||||
page
|
||||
.getByTestId(`terminal-profile-row-${id}`)
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedSettingsPath = await settingsIconPath(guessed.id);
|
||||
const explicitSettingsPath = await settingsIconPath(explicit.id);
|
||||
|
||||
expect(explicitMenuPath).not.toBeNull();
|
||||
expect(explicitSettingsPath).not.toBeNull();
|
||||
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
|
||||
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
|
||||
} finally {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||
},
|
||||
});
|
||||
|
||||
async function selectMedium(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function selectLongcat(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select model \(/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
async function askForReply(handle: AgentHandle) {
|
||||
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||
}
|
||||
|
||||
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
cwd,
|
||||
provider: "opencode",
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opencode/space-bunny-free" },
|
||||
});
|
||||
await selectMedium(page);
|
||||
expect((await askForReply(handle)).status).toBe("idle");
|
||||
await selectLongcat(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
const finish = await askForReply(handle);
|
||||
const screenshot = testInfo.outputPath("model-switch.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("After switching to Longcat", {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||
).toContainText("OK");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
LEGACY_PLUGIN_ID,
|
||||
SHOWCASE_PLUGIN_ID,
|
||||
WIDE,
|
||||
closeScreen,
|
||||
expectRowActive,
|
||||
footerItem,
|
||||
headerRow,
|
||||
installSidebarPlugins,
|
||||
popoverBox,
|
||||
runCommand,
|
||||
visibleTestId,
|
||||
} from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area?: Locator) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let popover, sheet and drawer animations settle so the image shows the final state.
|
||||
await page.waitForTimeout(600);
|
||||
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (area) await area.screenshot({ path: file });
|
||||
else await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
|
||||
async function expectFooterRow(page: Page, row: Locator) {
|
||||
// Polled: on compact the drawer is still sliding in when the row first shows.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
|
||||
const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
|
||||
const box = (await row.boundingBox())!;
|
||||
return {
|
||||
belowAddProject: box.y >= addProject.y + addProject.height,
|
||||
aboveIconRow: box.y + box.height <= hosts.y,
|
||||
alignedLeft: Math.abs(box.x - addProject.x) < 1,
|
||||
fullWidth: Math.abs(box.width - addProject.width) < 1,
|
||||
};
|
||||
})
|
||||
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
|
||||
}
|
||||
|
||||
function botRow(page: Page, rowId: string): Locator {
|
||||
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator) {
|
||||
const result = await locator.boundingBox();
|
||||
expect(result).not.toBeNull();
|
||||
return result!;
|
||||
}
|
||||
|
||||
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
|
||||
function screenTitle(page: Page): Locator {
|
||||
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
|
||||
}
|
||||
|
||||
async function expectBotScreen(page: Page, botId: string) {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
|
||||
);
|
||||
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
|
||||
// The plugin titles the screen from its params: the bot's name.
|
||||
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
|
||||
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
|
||||
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
|
||||
await expectRowActive(botRow(page, "status"), false);
|
||||
}
|
||||
|
||||
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
|
||||
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
|
||||
const popover = await popoverBox(page, text);
|
||||
const rowBox = await boxOf(row);
|
||||
const otherBox = await boxOf(elsewhere);
|
||||
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
|
||||
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
|
||||
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
|
||||
}
|
||||
|
||||
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
|
||||
function trackReleasedSubscriptions(page: Page): Set<string> {
|
||||
const released = new Set<string>();
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
|
||||
);
|
||||
return released;
|
||||
}
|
||||
|
||||
function collectRelease(released: Set<string>, payload: string | Buffer) {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.message?.type === "subscription.release.response")
|
||||
released.add(frame.message.payload.subscriptionId);
|
||||
}
|
||||
|
||||
async function expectReleased(released: Set<string>, subscriptionId: string) {
|
||||
await expect.poll(() => released.has(subscriptionId)).toBe(true);
|
||||
}
|
||||
|
||||
function readAlertsObservation(page: Page): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** The id of the observation the Bot alerts item opened after `previous`. */
|
||||
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
|
||||
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
|
||||
return (await readAlertsObservation(page))!;
|
||||
}
|
||||
|
||||
function sidebarFooter(page: Page): Locator {
|
||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||
}
|
||||
|
||||
test.describe("Plugin sidebar items", () => {
|
||||
let workspaceId: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
|
||||
const plugins = await installSidebarPlugins();
|
||||
workspaceId = workspace.workspaceId;
|
||||
cleanup = async () => {
|
||||
await plugins.cleanup();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sync).toBeVisible();
|
||||
|
||||
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
|
||||
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
|
||||
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the header row highlights while its screen is open", async () => {
|
||||
await expectRowActive(row, false);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Deploys");
|
||||
await expectRowActive(row, true);
|
||||
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
|
||||
await closeScreen(page);
|
||||
await expectRowActive(row, false);
|
||||
});
|
||||
|
||||
await test.step("the trailing button presses on its own", async () => {
|
||||
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
|
||||
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
|
||||
await expect(sync).toHaveAccessibleName("Sync");
|
||||
await expectFooterRow(page, sync);
|
||||
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
|
||||
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
|
||||
});
|
||||
|
||||
await test.step("the footer row opens a popover anchored to it", async () => {
|
||||
await sync.click();
|
||||
const popover = await popoverBox(page);
|
||||
const button = (await sync.boundingBox())!;
|
||||
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
|
||||
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
|
||||
expect(button.y - popover.y).toBeLessThan(300);
|
||||
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
|
||||
await qaScreenshot(page, "phase2-desktop-popover");
|
||||
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("the command center item opens the screen", async () => {
|
||||
await runCommand(page, "Open deploys");
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(row, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("on a compact layout the popover is a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
|
||||
const sheet = await popoverBox(page);
|
||||
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
|
||||
await qaScreenshot(page, "phase2-compact-sheet");
|
||||
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("a header row's shortcut hint presses the row", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
|
||||
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await newWorkspace.hover();
|
||||
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
|
||||
await expect(hint).toBeVisible();
|
||||
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
|
||||
await hint.click();
|
||||
await expect(page).toHaveURL(/\/new(\?|$)/);
|
||||
});
|
||||
|
||||
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.addInitScript(
|
||||
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
|
||||
{
|
||||
key: APP_SETTINGS_KEY,
|
||||
sidebarNavItems: [
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
let entryIcon = "";
|
||||
|
||||
await test.step("the saved order and visibility still apply", async () => {
|
||||
await expect(entry).toHaveAccessibleName("Legacy entry");
|
||||
await expect(entry.locator("svg")).toHaveCount(1);
|
||||
entryIcon = await entry.locator("svg").innerHTML();
|
||||
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
|
||||
const entryBox = (await entry.boundingBox())!;
|
||||
const newWorkspaceBox = (await visibleTestId(
|
||||
page,
|
||||
"sidebar-global-new-workspace",
|
||||
).boundingBox())!;
|
||||
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
|
||||
});
|
||||
|
||||
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
|
||||
await entry.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Legacy entry");
|
||||
await expectRowActive(entry, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("remembered sidebar routes keep resolving", async () => {
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
|
||||
);
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
|
||||
});
|
||||
|
||||
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
|
||||
const host = encodeURIComponent(getServerId());
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
|
||||
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(screenTitle(page)).toHaveText("Bot 2");
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
});
|
||||
|
||||
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const legacyIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const showcaseIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const footerIcon = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const markup = await showcaseIcon.innerHTML();
|
||||
expect(await footerIcon.innerHTML()).toBe(markup);
|
||||
expect(await legacyIcon.innerHTML()).not.toBe(markup);
|
||||
// The same registered icon the sidebar row draws.
|
||||
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
|
||||
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
|
||||
await qaScreenshot(
|
||||
page,
|
||||
"phase5-settings-sidebar-footer",
|
||||
page.getByTestId("sidebar-nav-section-footer"),
|
||||
);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
|
||||
await qaScreenshot(page, "phase2-compact-settings-sidebar");
|
||||
});
|
||||
});
|
||||
test("one item renders a group of rows, and each row opens its own screen params", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const bot1 = botRow(page, "bot-1");
|
||||
const bot2 = botRow(page, "bot-2");
|
||||
const status = botRow(page, "status");
|
||||
await expect(bot1).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("the rows and the separator render in order in the header", async () => {
|
||||
await expect(bot1).toHaveAccessibleName("Bot 1");
|
||||
await expect(bot2).toHaveAccessibleName("Bot 2");
|
||||
await expect(status).toHaveAccessibleName("Bot status");
|
||||
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
|
||||
expect(second.y).toBeGreaterThan(first.y);
|
||||
// The separator adds its line and margins between Bot 2 and the status row.
|
||||
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
|
||||
expect(second.y - (first.y + first.height)).toBeLessThan(4);
|
||||
});
|
||||
|
||||
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
|
||||
await bot2.click();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the screen and its params", async () => {
|
||||
await page.reload();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-bot-screen");
|
||||
});
|
||||
|
||||
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
|
||||
await bot1.click();
|
||||
await expectBotScreen(page, "bot-1");
|
||||
await page.goBack();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
});
|
||||
|
||||
await test.step("a popover opened from a later row anchors to that row", async () => {
|
||||
await status.click();
|
||||
await expectPopoverBeside(page, "Bot status details", status, bot1);
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
|
||||
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
|
||||
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
|
||||
await qaScreenshot(page, "phase6-desktop-popover-row");
|
||||
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
|
||||
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a param named like a route segment reaches the screen", async () => {
|
||||
await runCommand(page, "Open bot 3 on server x");
|
||||
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
// No bot named bot-3, so the title function returns its own fallback.
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
await page.reload();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Settings lists the group as one item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const header = page.getByTestId("sidebar-nav-section-header");
|
||||
await expect(
|
||||
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
|
||||
).toHaveCount(1);
|
||||
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
|
||||
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("the compact sidebar shows the same group", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "bot-1")).toBeInViewport();
|
||||
await expect(botRow(page, "status")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase6-compact-sidebar-bots");
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
await expect(alerts).toHaveAccessibleName("Bot alerts");
|
||||
await expectFooterRow(page, alerts);
|
||||
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const released = trackReleasedSubscriptions(page);
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
const first = await nextAlertsObservation(page, null);
|
||||
expect(released.has(first)).toBe(false);
|
||||
|
||||
await test.step("hiding the item in Settings closes its observation", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
|
||||
await expectReleased(released, first);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("removing the item through its remover closes its observation", async () => {
|
||||
await expect(alerts).toBeVisible();
|
||||
const second = await nextAlertsObservation(page, first);
|
||||
expect(released.has(second)).toBe(false);
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
await expectReleased(released, second);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,25 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
|
||||
test.describe("provider usage settings", () => {
|
||||
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
sourceLabel: "OpenUsage 0.6.27",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh invalidates and refetches usage", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("one provider error does not collapse the usage list", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Error", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("71%")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-usage-tooltip-",
|
||||
title: "Provider usage tooltip e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("provider usage tooltip", () => {
|
||||
test("fetches usage when the context tooltip opens and renders the active provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: 42,
|
||||
remainingPct: 58,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
|
||||
await page.getByTestId("context-window-meter").hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Test plan")).toBeVisible();
|
||||
await expect(page.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("42%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 41 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
|
||||
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
interface SeededSchedule {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
async function seedPausedSchedule(
|
||||
workspace: SeededWorkspace,
|
||||
name: string,
|
||||
): Promise<SeededSchedule> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
const { id, createdAt } = result.schedule;
|
||||
const paused = await client.schedulePause({ id });
|
||||
if (paused.error) {
|
||||
throw new Error(paused.error);
|
||||
}
|
||||
return { id, createdAt: Date.parse(createdAt) };
|
||||
}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleDelete({ id });
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
|
||||
async function openSchedulesAt(page: Page, time: number): Promise<void> {
|
||||
await page.clock.install({ time });
|
||||
await page.goto(buildSchedulesRoute());
|
||||
}
|
||||
|
||||
async function letTimePass(page: Page, duration: string): Promise<void> {
|
||||
await page.clock.fastForward(duration);
|
||||
}
|
||||
|
||||
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
|
||||
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Schedule relative timestamps", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
const failures: unknown[] = [];
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Test cleanup failed");
|
||||
}
|
||||
});
|
||||
|
||||
test("an idle schedule row keeps its created age current", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
|
||||
|
||||
await openSchedulesAt(page, schedule.createdAt);
|
||||
await expectScheduleRowText(page, schedule.id, "Created just now");
|
||||
|
||||
await letTimePass(page, "03:00");
|
||||
|
||||
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
expectFooterIconRow,
|
||||
expectFooterItemHidden,
|
||||
expectFooterOrder,
|
||||
expectFooterSettingsKeys,
|
||||
expectSidebarItemHidden,
|
||||
expectSidebarNavSettingsOrder,
|
||||
expectSidebarNavSettingsRow,
|
||||
expectSidebarOrder,
|
||||
expectStoredSidebarNav,
|
||||
leaveSettings,
|
||||
moveFooterItemUp,
|
||||
moveSidebarNavItemUp,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarFooterPreferences,
|
||||
seedSidebarNavPreferences,
|
||||
setFooterItemVisible,
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
@@ -24,8 +32,8 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await test.step("the Sidebar section lists every item in the same order", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
|
||||
"About Header",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
@@ -106,3 +114,63 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await expectSidebarItemHidden(page, "schedules");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
|
||||
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ cleanup } = await installSidebarPlugins());
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "help", visible: false },
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the Footer card lists only the footer rows", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
|
||||
"About Footer",
|
||||
);
|
||||
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
|
||||
await leaveSettings(page);
|
||||
await expectFooterOrder(page, ["usage", syncKey]);
|
||||
await expectFooterIconRow(page);
|
||||
});
|
||||
|
||||
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await moveFooterItemUp(page, syncKey);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
await leaveSettings(page);
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expect(
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).toBeVisible();
|
||||
await expectFooterIconRow(page);
|
||||
});
|
||||
|
||||
await test.step("the rows keep that shape across a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expectFooterIconRow(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||
.click();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
|
||||
const left = page.getByTestId("workspace-pane-main");
|
||||
const right = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||
}, SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
const childPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||
await expect(childPane).toBeVisible();
|
||||
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||
await right.getAttribute("data-testid"),
|
||||
);
|
||||
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-file-origin-pane-",
|
||||
title: "File link parent",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const { left, right } = await openParentInRightPane(
|
||||
page,
|
||||
workspace.agentId,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const fileLink = right.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
/** The host selector names the host shown, even when it is the only one. */
|
||||
async function shownHostLabel(page: Page): Promise<string> {
|
||||
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
|
||||
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
|
||||
return (await selector.innerText()).trim();
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
|
||||
`Usage host: ${hostLabel}`,
|
||||
);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").first().hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const beta: UsageReportEntry = {
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt,
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[alpha(31, twoHoursAgo()), beta],
|
||||
() => [
|
||||
alpha(58, new Date().toISOString()),
|
||||
{ ...beta, fetchedAt: new Date().toISOString() },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: undefined },
|
||||
{ forceRefresh: true, reportIds: ["alpha:a"] },
|
||||
]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await alphaRefresh.hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
});
|
||||
|
||||
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { status: "available", windows: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
});
|
||||
|
||||
test("tells the user to update a host without usage support", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
function report(input: {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
report: Partial<UsageReportEntry["report"]>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined },
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage settings", () => {
|
||||
test("renders every report returned by usage.list_reports", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
planLabel: "Max",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
planLabel: "Coding plan",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "gamma",
|
||||
sourceLabel: "Gamma plan",
|
||||
report: { status: "error", error: "Gamma auth expired" },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await card.getByTestId("usage-display-remaining").click();
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh forces a fresh report", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(23) },
|
||||
}),
|
||||
],
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(64) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usage.waitForListRequests(2);
|
||||
|
||||
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
// Names the host: "Update Laptop to see usage".
|
||||
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectNoPinnedUsage,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
pinRow,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let sheet and drawer animations settle so the image shows the final state.
|
||||
await page.waitForTimeout(600);
|
||||
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `phase4-${name}.png`);
|
||||
if (area.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** The sidebar footer, from Add project down to the icon row, with some margin. */
|
||||
async function footerClip(page: Page) {
|
||||
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
|
||||
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
|
||||
const margin = 16;
|
||||
const x = Math.max(0, top.x - margin);
|
||||
const y = Math.max(0, top.y - margin);
|
||||
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: the selector still names it.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["31%", "12%"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
|
||||
// The row reads its window, percent and reset; the checkbox state says it is pinned.
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the Usage screen's controls, host selector included.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoPinnedUsage(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const footer = page.getByTestId("sidebar-nav-section-footer");
|
||||
await expect(
|
||||
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
|
||||
).toBeVisible();
|
||||
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
|
||||
await leaveSettings(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
restoreArchivedWorkspace,
|
||||
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `branchless-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(backingWorktree.projectKey);
|
||||
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
|
||||
|
||||
execFileSync("git", ["switch", "--detach", "main"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const created = await worktreeClient.createWorkspace({
|
||||
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
|
||||
title: "Sample workspace",
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Could not create the branchless workspace");
|
||||
}
|
||||
const workspaceId = created.workspace.id;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
workspaceId,
|
||||
title: "Archived agent",
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
|
||||
scope: "worktree",
|
||||
});
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
|
||||
|
||||
await page.getByRole("button", { name: "Restore", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
|
||||
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
|
||||
cwd: tempRepo.path,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
expect(restoredHead).toBe(defaultHead);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toMatch(/^restored\//);
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
function commitRestorableChange(cwd: string): void {
|
||||
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
|
||||
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import readline from "node:readline";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
|
||||
const ultra = {
|
||||
id: "ultrafast",
|
||||
name: "Ultrafast",
|
||||
description: "Lowest latency, increased usage",
|
||||
};
|
||||
const models = [
|
||||
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
|
||||
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
|
||||
].map((model) =>
|
||||
Object.assign(model, {
|
||||
model: model.id,
|
||||
defaultReasoningEffort: "medium",
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
|
||||
}),
|
||||
);
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n");
|
||||
}
|
||||
function respond(method, params) {
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
return {};
|
||||
case "model/list":
|
||||
return { data: models };
|
||||
case "config/read":
|
||||
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
|
||||
case "collaborationMode/list":
|
||||
return { data: [] };
|
||||
case "skills/list":
|
||||
return { data: [] };
|
||||
case "thread/list":
|
||||
case "thread/loaded/list":
|
||||
return { data: [] };
|
||||
case "thread/start":
|
||||
return { thread: { id: randomUUID() }, model: params.model };
|
||||
case "thread/resume":
|
||||
return { thread: { id: params.threadId } };
|
||||
case "thread/read":
|
||||
return { thread: { id: params.threadId, turns: [] } };
|
||||
case "turn/start": {
|
||||
const turnId = randomUUID();
|
||||
setTimeout(() => {
|
||||
send({
|
||||
method: "turn/started",
|
||||
params: { threadId: params.threadId, turn: { id: turnId } },
|
||||
});
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
item: {
|
||||
id: randomUUID(),
|
||||
type: "agentMessage",
|
||||
text: "Ready to work on this project.",
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
|
||||
});
|
||||
}, 50);
|
||||
return { turn: { id: turnId } };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported Codex fixture request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("codex-cli 0.159.0");
|
||||
} else {
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
try {
|
||||
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
|
||||
} catch (error) {
|
||||
send({ id: request.id, error: { code: -32601, message: error.message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run only on a task-owned foldable emulator with a connected Paseo debug app.
|
||||
# Usage: native-fold-history-route.android.sh <adb-serial> <avd-name> <agent-device-session> <workspace-name>
|
||||
serial=${1:?adb serial required}
|
||||
avd_name=${2:?expected AVD name required}
|
||||
session=${3:?agent-device session required}
|
||||
workspace_name=${4:?workspace name required}
|
||||
|
||||
actual_avd=$(adb -s "$serial" shell getprop ro.boot.qemu.avd_name | tr -d '\r')
|
||||
if [[ "$actual_avd" != "$avd_name" ]]; then
|
||||
echo "Refusing device $serial: expected $avd_name, found $actual_avd" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
initial_state=$(adb -s "$serial" shell cmd device_state print-state | tr -d '\r')
|
||||
if [[ "$initial_state" != "0" && "$initial_state" != "2" ]]; then
|
||||
echo "Expected CLOSED=0 or OPENED=2; got $initial_state" >&2
|
||||
exit 2
|
||||
fi
|
||||
trap 'adb -s "$serial" shell cmd device_state state "$initial_state" >/dev/null' EXIT
|
||||
|
||||
wait_for_history() {
|
||||
local snapshot
|
||||
for _ in {1..20}; do
|
||||
snapshot=$(agent-device snapshot --session "$session" -i)
|
||||
if rg -q '\[group\] "History"' <<<"$snapshot"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "$snapshot" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_compact_workspace() {
|
||||
local snapshot
|
||||
for _ in {1..20}; do
|
||||
snapshot=$(agent-device snapshot --session "$session" -i)
|
||||
if rg -q 'Unable to find node on an unmounted component|RootErrorBoundary' <<<"$snapshot"; then
|
||||
echo "Workspace hit a React Native error after folding" >&2
|
||||
echo "$snapshot" >&2
|
||||
return 1
|
||||
fi
|
||||
if rg -q 'Switch tabs \(' <<<"$snapshot"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "$snapshot" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
adb -s "$serial" shell cmd device_state state 2
|
||||
sleep 3
|
||||
agent-device press 'text="History"' --session "$session"
|
||||
if ! wait_for_history; then
|
||||
echo "History route did not open on the wide foldable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
adb -s "$serial" shell cmd device_state state 0
|
||||
sleep 4
|
||||
if ! wait_for_history; then
|
||||
echo "History route was lost after folding CLOSED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
adb -s "$serial" shell cmd device_state state 2
|
||||
sleep 4
|
||||
if ! wait_for_history; then
|
||||
echo "History route was lost after unfolding OPENED" >&2
|
||||
exit 1
|
||||
fi
|
||||
agent-device press "label=\"$workspace_name\"" --session "$session"
|
||||
adb -s "$serial" shell cmd device_state state 0
|
||||
sleep 4
|
||||
if ! wait_for_compact_workspace; then
|
||||
echo "Compact workspace failed after returning from History" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "History retained and compact workspace mounted after OPENED -> CLOSED -> OPENED -> CLOSED"
|
||||
@@ -0,0 +1,5 @@
|
||||
# Android fold-open flow
|
||||
|
||||
Use a task-owned foldable emulator with real `cmd device_state` CLOSED (0) and OPENED (2) states, a connected development app, and an unsent draft. The recorded run used `paseo-fold-p3a` (Pixel 9 Pro Fold, API 35). Set `DRAFT_TEXT` to the exact composer value. Run the flow after a CLOSED → OPENED transition and confirm `adb -s <serial> shell cmd device_state print-state` returns `2`. `workspace-tabs-scroll` and the Settings footer prove the wide shell is usable. A regular tablet can satisfy those UI assertions, so the device-state precondition is part of the test, not inferred from the flow.
|
||||
|
||||
The `.ad` flow opens the app to establish an agent-device session. For the Gboard transition check, keep the app foreground, show Gboard with a real unsent draft, change device state with `cmd device_state state 0` then `state 2`, and capture the app before reopening it. An app-open step can recover a missed native layout callback and cannot prove that the immediate transition was correct. Retain before/after screenshots, a post-transition accessibility snapshot, `dumpsys activity` window bounds, and `dumpsys input_method` IME state. Repeat with the keyboard hidden, and verify CLOSED still presents the compact tab switcher.
|
||||
@@ -0,0 +1,8 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 12000
|
||||
wait "label=\"${DRAFT_TEXT}\"" 10000
|
||||
wait "label=\"Settings\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,24 @@
|
||||
context platform=android timeout=90000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-new-tab-button\"" 45000
|
||||
press "id=\"workspace-new-tab-button\""
|
||||
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
|
||||
press "id=\"workspace-new-tab-menu-terminal\""
|
||||
wait "id=\"terminal-key-esc\"" 10000
|
||||
is visible "id=\"terminal-virtual-keyboard-native-wide\""
|
||||
is visible "id=\"terminal-key-esc\""
|
||||
is visible "id=\"terminal-key-ctrl\""
|
||||
is visible "id=\"terminal-key-enter\""
|
||||
is visible "id=\"terminal-paste\""
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000
|
||||
press "id=\"terminal-key-ctrl\""
|
||||
type "c" --delay-ms 0
|
||||
type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_MODIFIER_RESET_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=ios timeout=90000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-new-tab-button\"" 30000
|
||||
press "id=\"workspace-new-tab-button\""
|
||||
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
|
||||
press "id=\"workspace-new-tab-menu-terminal\""
|
||||
wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000
|
||||
is visible "id=\"terminal-key-esc\""
|
||||
is visible "id=\"terminal-key-enter\""
|
||||
is visible "id=\"terminal-paste\""
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_IOS_KEY_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-new-tab-button
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: workspace-new-tab-button
|
||||
- tapOn:
|
||||
id: workspace-new-tab-menu-terminal
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: terminal-virtual-keyboard-native-wide
|
||||
timeout: 10000
|
||||
- assertVisible:
|
||||
id: terminal-key-esc
|
||||
- assertVisible:
|
||||
id: terminal-key-enter
|
||||
- assertVisible:
|
||||
id: terminal-paste
|
||||
- tapOn:
|
||||
id: terminal-paste
|
||||
- runFlow:
|
||||
when:
|
||||
visible: Allow Paste
|
||||
commands:
|
||||
- tapOn: Allow Paste
|
||||
- tapOn:
|
||||
id: terminal-key-enter
|
||||
- extendedWaitUntil:
|
||||
visible: __AD_IOS_PASTE_OK__
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,18 @@
|
||||
# Native wide terminal device flows
|
||||
|
||||
Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device.
|
||||
|
||||
Android Pixel Tablet API 36.1:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36
|
||||
```
|
||||
|
||||
iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`.
|
||||
|
||||
```sh
|
||||
printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
|
||||
```
|
||||
|
||||
The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states.
|
||||
@@ -0,0 +1,12 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
|
||||
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
wait "id=\"workspace-tabs-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
|
||||
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"context-window-meter\"" 20000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"context-window-meter\"" 10000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"context-window-meter\"" 20000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"context-window-meter\"" 10000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,33 @@
|
||||
# Native wide menu flows
|
||||
|
||||
Run these on a connected task-owned tablet with the current development app and Metro bundle. `workspace-tabs-scroll` asserts the wide tab row; also pass an explicit tablet `--device` so a landscape phone above the breakpoint cannot stand in for tablet coverage. The context meter flow requires an agent tab with reported token usage selected. Its tooltip assertion uses a stable ID emitted only by the open content.
|
||||
|
||||
The agent tab menu flow requires an existing agent tab. Set `AGENT_ID` to the ID of that agent and `TAB_ID` to its workspace tab ID (shown in the `workspace-tab-context-*` test ID) before replay. The flow long-presses the tab and selects **Copy agent id**. It does not claim that an arrow tap works.
|
||||
|
||||
The sidebar Rename flow requires a selected workspace and its `WORKSPACE_KEY` (shown in the `sidebar-workspace-kebab-*` test ID). It opens the row's actions, opens Rename, then cancels without changing the workspace name. This covers the currently working row reported in #5226.
|
||||
|
||||
For the native-wide tooltip dismissal flows, select an agent with reported token usage so `context-window-meter` exists. A fresh load-test stream can supply usage. The Maestro flows assert that the tooltip is absent after dismissal, then reopen it to prove the control is usable. The overlay dismissal flows assert that the tab menu and Rename dialog are absent after selection/cancel. On iPad the focused Rename input can consume the first Cancel tap; the iOS Maestro flow conditionally repeats that tap only while the dialog remains visible.
|
||||
|
||||
For tab scrolling, first create enough tabs for actual horizontal overflow. The Android replay was validated with nine tabs and the Explorer sidebar open; the iPad replay was validated with six tabs. Set `DRAFT_ID` to a visible draft tab, `AGENT_ID` to the first visible agent tab, `OTHER_AGENT_ID` to another agent tab, and `TAB_ID` to the first agent's workspace tab ID. The short pan starts on an agent chip while a draft is selected; the draft composer must remain active. The flow then checks a stationary long press and an ordinary tap. Keep the tab row scrolled to its beginning before running the iPad flow.
|
||||
|
||||
Android Pixel Tablet API 36.1:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad --platform android --device paseo-tablet-p1-api36 --env OTHER_AGENT_ID=<other-agent-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad --platform android --device paseo-tablet-p1-api36 --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id> --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad --platform android --device paseo-tablet-p1-api36 --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
```
|
||||
|
||||
iPad Pro 11-inch (M5), iOS 26.5, from the Namespace Mac checkout:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env OTHER_AGENT_ID=<other-agent-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-tabs-scroll
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- longPressOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- assertVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- assertNotVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- longPressOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- assertVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- assertNotVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- stopApp
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-tabs-scroll
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
commands:
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
timeout: 10000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- stopApp
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
wait "id=\"workspace-tabs-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,16 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
gesture pan 900 155 550 0 280
|
||||
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
|
||||
wait "id=\"composer-import-agent-pill\"" 10000
|
||||
gesture pan 770 155 -310 0 280
|
||||
is visible "id=\"composer-import-agent-pill\""
|
||||
gesture pan 900 155 550 0 280
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
|
||||
press "label=\"Menu backdrop\""
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,16 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
|
||||
wait "id=\"composer-import-agent-pill\"" 10000
|
||||
gesture pan 450 86 -210 0 280
|
||||
is visible "id=\"composer-import-agent-pill\""
|
||||
gesture pan 450 86 240 0 280
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
|
||||
press "label=\"Menu backdrop\""
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: context-window-meter
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
@@ -0,0 +1,30 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: context-window-meter
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
@@ -0,0 +1,26 @@
|
||||
# Native iOS text geometry regression
|
||||
|
||||
Run on the Namespace Mac with a fresh simulator development build. Use the PID
|
||||
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
|
||||
Attach only to your task's app, while no device replay is running:
|
||||
|
||||
```sh
|
||||
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
|
||||
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
|
||||
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
```
|
||||
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
This covers native container resizing and selection retention. It does not cover
|
||||
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
|
||||
menu. Those require the real chat journey: read an older multiline response,
|
||||
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
|
||||
retain expanded/collapsed details, and check a stationary reader's position.
|
||||
Accessibility exposes full text even when glyphs are clipped, so a text wait
|
||||
alone does not verify that journey.
|
||||
@@ -0,0 +1,55 @@
|
||||
import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
auto hostFrame = host.frame;
|
||||
hostFrame.size.width = 500;
|
||||
hostFrame.size.height = 200;
|
||||
host.frame = hostFrame;
|
||||
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
|
||||
container.frame = host.bounds;
|
||||
UITextView *text = nil;
|
||||
for (UIView *child in host.subviews) {
|
||||
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
|
||||
for (UIView *nested in child.subviews) {
|
||||
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
|
||||
}
|
||||
}
|
||||
text.frame = container.bounds;
|
||||
text.font = [UIFont systemFontOfSize:16];
|
||||
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
|
||||
auto range = text.selectedRange;
|
||||
range.location = 12;
|
||||
range.length = 9;
|
||||
text.selectedRange = range;
|
||||
auto frame = container.frame;
|
||||
frame.size.width = 240;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat narrow = text.frame.size.width;
|
||||
auto selection = text.selectedRange;
|
||||
frame.size.width = 500;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
selection.location == text.selectedRange.location &&
|
||||
selection.length == text.selectedRange.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide,
|
||||
(unsigned long)text.selectedRange.location,
|
||||
(unsigned long)text.selectedRange.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
if result.GetError().Fail():
|
||||
raise RuntimeError(result.GetError().GetCString())
|
||||
print(result.GetObjectDescription())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
|
||||
|
||||
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
|
||||
echo "Expected a running PaseoDebug simulator app" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
|
||||
-o "command script import $probe_dir/ios.py" \
|
||||
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
|
||||
|
||||
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
|
||||
import { openSettingsSection } from "./settings";
|
||||
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { loadSessionMessageReaders } from "./new-workspace";
|
||||
|
||||
export async function withStreamingMarkdownOutline(
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
@@ -229,3 +230,22 @@ async function requireBoundingBox(
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export async function observePromptIndexRequests(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
const agentIds: string[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = frames.client(payload);
|
||||
if (request?.type === "agent.timeline.list_prompts.request") {
|
||||
agentIds.push(request.agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
async waitForRequestFor(agentId: string) {
|
||||
await expect.poll(() => agentIds).toContain(agentId);
|
||||
},
|
||||
requestedAgentIds: () => [...new Set(agentIds)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { openSettingsSection } from "./settings";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function contentWidthInput(page: Page): Locator {
|
||||
return page.getByLabel("Content width in pixels", { exact: true });
|
||||
}
|
||||
|
||||
function resetContentWidthButton(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Reset content width to default", exact: true });
|
||||
}
|
||||
|
||||
export async function openAppearanceSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
export async function setContentWidth(page: Page, width: number): Promise<void> {
|
||||
const input = contentWidthInput(page);
|
||||
await input.fill(String(width));
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
export async function resetContentWidth(page: Page): Promise<void> {
|
||||
await resetContentWidthButton(page).click();
|
||||
}
|
||||
|
||||
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
|
||||
await expect(contentWidthInput(page)).toHaveValue(String(width));
|
||||
await expect(resetContentWidthButton(page)).toHaveCount(0);
|
||||
await expect.poll(() => readStoredContentWidth(page)).toBeNull();
|
||||
}
|
||||
|
||||
export async function expectCustomContentWidthSetting(page: Page, width: number): Promise<void> {
|
||||
await expect(contentWidthInput(page)).toHaveValue(String(width));
|
||||
await expect(resetContentWidthButton(page)).toBeVisible();
|
||||
await expect.poll(() => readStoredContentWidth(page)).toBe(width);
|
||||
}
|
||||
|
||||
/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
|
||||
export async function expectContentColumnWidth(
|
||||
element: Locator,
|
||||
bounds: { min?: number; max: number },
|
||||
): Promise<void> {
|
||||
await expect(element).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => (await element.boundingBox())?.width ?? 0)
|
||||
.toBeLessThanOrEqual(bounds.max);
|
||||
await expect
|
||||
.poll(async () => (await element.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(bounds.min ?? 0);
|
||||
}
|
||||
|
||||
async function readStoredContentWidth(page: Page): Promise<unknown> {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null
|
||||
? undefined
|
||||
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
|
||||
}, APP_SETTINGS_KEY);
|
||||
}
|
||||
@@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) {
|
||||
)
|
||||
.toBe(count);
|
||||
},
|
||||
async agentIds() {
|
||||
await requests.settled();
|
||||
return (await project.client.fetchAgents()).entries
|
||||
.filter(({ agent }) => agent.workspaceId === workspaceId)
|
||||
.map(({ agent }) => agent.id);
|
||||
},
|
||||
async expectAgentTitle(title: string) {
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -41,6 +41,13 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAvailableHostDaemonPort(): Promise<number> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 90_000;
|
||||
let lastError: unknown = null;
|
||||
@@ -81,9 +88,7 @@ export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
@@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) {
|
||||
React.useEffect(() => {
|
||||
const owner = paseo.observeEvents(["project.update"]);
|
||||
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
|
||||
// Deliberately leave this observation to the mounted host scope.
|
||||
return () => { void owner.release(); };
|
||||
}, [paseo]);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 { expectAppRoute } from "./route-assertions";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
|
||||
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
|
||||
export const BOTS_PLUGIN_ID = "sidebar-bots";
|
||||
export const WIDE = { width: 1440, height: 900 };
|
||||
export const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
/** A plugin written against the current API: a screen, header and footer items, a command. */
|
||||
const SHOWCASE_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function DeploysScreen({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Deploys screen body</Text></View>;
|
||||
}
|
||||
|
||||
function RefreshButton({ theme, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Refresh deploys" onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Refresh</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploysItem({ theme, currentScreen, openScreen }) {
|
||||
const [refreshes, setRefreshes] = React.useState(0);
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="Rocket"
|
||||
label={refreshes === 0 ? undefined : "Deploys (refreshed " + refreshes + ")"}
|
||||
active={currentScreen?.screenId === "deploys"}
|
||||
onPress={() => openScreen({ screenId: "deploys" })}
|
||||
trailing={<RefreshButton theme={theme} onPress={() => setRefreshes((count) => count + 1)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncDetails({ theme, layout, close, openScreen }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Sync details</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Open deploys from popover" onPress={() => openScreen({ screenId: "deploys" })}><Text style={{ color: theme.colors.foreground }}>Open deploys</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close sync details" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncItem({ openPopover }) {
|
||||
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("Sidebar item exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
|
||||
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
|
||||
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
|
||||
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
|
||||
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
|
||||
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
|
||||
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
|
||||
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
|
||||
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
|
||||
* id on `globalThis.__botAlertsObservation`.
|
||||
*/
|
||||
const BOTS_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
const BOTS = [
|
||||
{ id: "bot-1", name: "Bot 1" },
|
||||
{ id: "bot-2", name: "Bot 2" },
|
||||
];
|
||||
|
||||
function BotScreen({ theme, params }) {
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 24, gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{"Bot screen: " + params.botId}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{"serverId param: " + (params.serverId ?? "none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function botTitle(params) {
|
||||
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
|
||||
}
|
||||
|
||||
const botMenus = {};
|
||||
|
||||
function botMenu(bot) {
|
||||
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{bot.name + " options"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"Close " + bot.name + " options"} onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return botMenus[bot.id];
|
||||
}
|
||||
|
||||
function MoreButton({ theme, bot, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"More for " + bot.name} onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>More</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function BotStatus({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Bot status details</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close bot status" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
|
||||
return (
|
||||
<>
|
||||
{BOTS.map((bot) => (
|
||||
<SidebarRow
|
||||
key={bot.id}
|
||||
id={bot.id}
|
||||
icon="Bot"
|
||||
label={bot.name}
|
||||
active={currentScreen?.screenId === "bot" && currentScreen.params.botId === bot.id}
|
||||
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
|
||||
trailing={<MoreButton theme={theme} bot={bot} onPress={() => openPopover(botMenu(bot))} />}
|
||||
/>
|
||||
))}
|
||||
<SidebarSeparator />
|
||||
<SidebarRow id="status" icon="Activity" label="Bot status" onPress={() => openPopover(BotStatus)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsItem() {
|
||||
const paseo = usePaseo();
|
||||
React.useEffect(() => {
|
||||
const observation = paseo.observeEvents(["project.update"]);
|
||||
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
|
||||
return () => { void observation.release(); };
|
||||
}, [paseo]);
|
||||
return <SidebarRow icon="Bell" onPress={() => {}} />;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
let removeAlerts = null;
|
||||
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
|
||||
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
|
||||
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
|
||||
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
|
||||
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
|
||||
} });
|
||||
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
|
||||
removeAlerts?.();
|
||||
removeAlerts = null;
|
||||
} });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/** A plugin that still uses only the old descriptor calls. */
|
||||
const LEGACY_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
function Main({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Legacy screen body</Text></View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Main);
|
||||
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
|
||||
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
export async function installSidebarPlugins() {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directories: string[] = [];
|
||||
for (const [id, source] of [
|
||||
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
|
||||
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
|
||||
[BOTS_PLUGIN_ID, BOTS_SOURCE],
|
||||
] as const) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), source);
|
||||
}
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
for (const directory of directories) await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
async cleanup() {
|
||||
await client.removePlugin(SHOWCASE_PLUGIN_ID);
|
||||
await client.removePlugin(LEGACY_PLUGIN_ID);
|
||||
await client.removePlugin(BOTS_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await Promise.all(
|
||||
directories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
export function visibleTestId(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export async function expectRowActive(row: Locator, active: boolean): Promise<void> {
|
||||
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
else await expect(row).not.toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeScreen(page: Page): Promise<void> {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
}
|
||||
|
||||
/** Box of the popover body, located from the text it renders. */
|
||||
export async function popoverBox(page: Page, text = "Sync details") {
|
||||
const details = page.getByText(text, { exact: true });
|
||||
await expect(details).toBeInViewport();
|
||||
const box = await details.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function openSettingsHostSection(
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
|
||||
@@ -55,10 +55,25 @@ export async function seedSidebarNavPreferences(
|
||||
);
|
||||
}
|
||||
|
||||
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
|
||||
export async function seedSidebarFooterPreferences(
|
||||
page: Page,
|
||||
preferences: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function leaveSettings(page: Page): Promise<void> {
|
||||
@@ -125,9 +140,9 @@ export async function expectStoredSidebarNav(
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectVerticalOrder(
|
||||
keys: SidebarNavKey[],
|
||||
locate: (key: SidebarNavKey) => Locator,
|
||||
async function expectVerticalOrder<Key extends string>(
|
||||
keys: Key[],
|
||||
locate: (key: Key) => Locator,
|
||||
subject: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
@@ -136,11 +151,7 @@ async function expectVerticalOrder(
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
@@ -148,3 +159,83 @@ async function expectVerticalOrder(
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
// With nothing pinned the Usage item is the plain Usage row.
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
}
|
||||
|
||||
function shellFooterRow(page: Page, key: string): Locator {
|
||||
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
|
||||
}
|
||||
|
||||
function footerSettingsRows(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.locator('[data-testid^="sidebar-nav-item-"]');
|
||||
}
|
||||
|
||||
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
await footerSettingsRows(page).evaluateAll((rows) =>
|
||||
rows.map((row) => row.getAttribute("data-testid")),
|
||||
)
|
||||
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-move-up-${key}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
|
||||
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
|
||||
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-import-session",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
);
|
||||
const [first, ...rest] = boxes.map((box) => box!);
|
||||
let previous = first;
|
||||
for (const box of rest) {
|
||||
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
|
||||
expect(box.x).toBeGreaterThan(previous.x);
|
||||
previous = box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
/**
|
||||
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null;
|
||||
if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; payload?: Record<string, unknown> };
|
||||
} | null;
|
||||
const payload = envelope?.message?.payload;
|
||||
if (
|
||||
envelope?.type !== "session" ||
|
||||
envelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const features =
|
||||
typeof payload.features === "object" && payload.features !== null ? payload.features : {};
|
||||
return JSON.stringify({
|
||||
...envelope,
|
||||
message: {
|
||||
...envelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: { ...features, usageSources: enabled, providerUsageList: enabled },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pick<T>(values: T[], index: number): T {
|
||||
const value = values[Math.min(index, values.length - 1)];
|
||||
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function createCounter() {
|
||||
let count = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
return {
|
||||
increment() {
|
||||
count += 1;
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (count >= waiter.count) waiter.resolve();
|
||||
else waiters.push(waiter);
|
||||
}
|
||||
},
|
||||
waitFor(target: number): Promise<void> {
|
||||
if (count >= target) return Promise.resolve();
|
||||
return new Promise((resolve) => waiters.push({ count: target, resolve }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installUsageReportsFixture(
|
||||
page: Page,
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const listCounter = createCounter();
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = getSessionMessage(message);
|
||||
const requestId = request?.requestId;
|
||||
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
|
||||
listRequests.push({
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
});
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const response = typeof scripted === "function" ? scripted() : scripted;
|
||||
if ("error" in response) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
|
||||
|
||||
function sourceIcon(plugin: string): string {
|
||||
return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8");
|
||||
}
|
||||
|
||||
function inOneDay(): string {
|
||||
return new Date(Date.now() + 24 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Claude and Codex reports shaped like the built-in sources report them. */
|
||||
export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: "claude:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
icon: sourceIcon("claude-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "codex",
|
||||
sourceLabel: "Codex",
|
||||
icon: sourceIcon("codex-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
|
||||
/** The compact usage sheet the Usage item opens. */
|
||||
export function usageSheet(page: Page): Locator {
|
||||
return visible(page, "usage-expanded");
|
||||
}
|
||||
|
||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
|
||||
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(windows).toHaveText(percents);
|
||||
}
|
||||
|
||||
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
|
||||
export async function expectNoPinnedUsage(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
|
||||
await expect(usageItem(page)).toHaveText("Usage");
|
||||
}
|
||||
|
||||
/** A window row, which is itself the pin toggle: "Claude", "Session". */
|
||||
export function pinRow(scope: Locator, source: string, window: string): Locator {
|
||||
// The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h".
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
await row.click();
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
|
||||
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
|
||||
}
|
||||
|
||||
/** Opens the compact sidebar drawer. */
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
|
||||
export async function leaveUsageScreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
|
||||
__dirname,
|
||||
"../../../../server/src/server/test-utils/versioned-daemon.ts",
|
||||
);
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
|
||||
// A host daemon that can be replaced by a daemon reporting another version under the same
|
||||
// endpoint and serverId, the way an upgraded host comes back.
|
||||
export interface RestartableHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
stop(): Promise<void>;
|
||||
restartWithVersion(version: string): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
let current: VersionedHostDaemon | null = null;
|
||||
async function dispose(): Promise<void> {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
const { serverId } = current;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
async stop() {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
},
|
||||
async restartWithVersion(nextVersion) {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
|
||||
if (current.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
interface VersionedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
version: string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface VersionedHostDaemonOptions {
|
||||
version: string;
|
||||
port: number;
|
||||
paseoHomeRoot: string;
|
||||
}
|
||||
|
||||
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
|
||||
// removes the home root.
|
||||
async function startVersionedHostDaemon(
|
||||
options: VersionedHostDaemonOptions,
|
||||
): Promise<VersionedHostDaemon> {
|
||||
const child = spawnTsx(
|
||||
VERSIONED_DAEMON_ENTRYPOINT,
|
||||
[options.version, String(options.port), options.paseoHomeRoot],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.once("error", ready.reject);
|
||||
child.once("exit", () => {
|
||||
ready.reject(
|
||||
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
|
||||
);
|
||||
});
|
||||
child.once("message", (message) => {
|
||||
if (!isReadyMessage(message)) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve(message);
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
ready.reject(
|
||||
new Error(
|
||||
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
|
||||
),
|
||||
),
|
||||
READY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let stopped = false;
|
||||
async function stop(): Promise<void> {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await killProcessTree(child);
|
||||
}
|
||||
|
||||
try {
|
||||
const readyMessage = await ready.promise;
|
||||
if (readyMessage.port !== options.port) {
|
||||
throw new Error(
|
||||
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
serverId: readyMessage.serverId,
|
||||
port: readyMessage.port,
|
||||
version: options.version,
|
||||
stop,
|
||||
};
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// A replacement daemon can only bind the port the stopped daemon just released.
|
||||
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isPortAvailable(port)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"port" in message &&
|
||||
typeof message.port === "number" &&
|
||||
"serverId" in message &&
|
||||
typeof message.serverId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'sh.paseo.screengeometry'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
useDefaultAndroidSdkVersions()
|
||||
|
||||
android {
|
||||
namespace "sh.paseo.screengeometry"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package sh.paseo.screengeometry
|
||||
|
||||
import android.os.Build
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class PaseoScreenGeometryModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoScreenGeometry")
|
||||
|
||||
Function("getPhysicalScreenSize") {
|
||||
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
activity.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.windowManager.defaultDisplay
|
||||
}
|
||||
val mode = display.mode
|
||||
val density = activity.resources.displayMetrics.density
|
||||
mapOf(
|
||||
"width" to mode.physicalWidth / density,
|
||||
"height" to mode.physicalHeight / density,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "paseo-screen-geometry",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-screen-orientation": "~9.0.9",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
function withAndroidFoldConfiguration(config) {
|
||||
return withAndroidManifest(config, (modConfig) => {
|
||||
const activity = modConfig.modResults.manifest.application?.[0]?.activity?.find(
|
||||
(entry) => entry.$?.["android:name"] === ".MainActivity",
|
||||
);
|
||||
if (!activity) {
|
||||
throw new Error("Could not configure fold changes without MainActivity");
|
||||
}
|
||||
|
||||
// A fold changes smallestScreenWidthDp as well as screenSize. Let React Native and
|
||||
// Unistyles receive the configuration change without Android recreating MainActivity.
|
||||
const changes = new Set((activity.$["android:configChanges"] ?? "").split("|").filter(Boolean));
|
||||
changes.add("smallestScreenSize");
|
||||
activity.$["android:configChanges"] = [...changes].join("|");
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidFoldConfiguration;
|
||||
@@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => {
|
||||
it("accepts only the authoritative timeline epoch", () => {
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
||||
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
|
||||
|
||||
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||
|
||||
export function shouldAcceptPromptIndexEpoch(
|
||||
timelineEpoch: string | null,
|
||||
indexEpoch: string,
|
||||
): boolean {
|
||||
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
||||
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
|
||||
return timelineEpoch === indexEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
|
||||
runtime.subscribeAgentTimeline.mockClear();
|
||||
});
|
||||
|
||||
it("waits for a served timeline before requesting its prompt index", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { rerender } = renderHook(
|
||||
({ agentId, timelineEpoch }) =>
|
||||
useChatOutline({
|
||||
agentId,
|
||||
serverId: "server-1",
|
||||
timelineEpoch,
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
|
||||
);
|
||||
|
||||
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
|
||||
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||
const second = deferred<{
|
||||
|
||||
@@ -77,8 +77,10 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
|
||||
// epoch, and its id names no agent the daemon knows.
|
||||
useEffect(() => {
|
||||
if (!isWeb || !enabled) {
|
||||
if (!isWeb || !enabled || timelineEpoch === null) {
|
||||
setIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ function installProbe(
|
||||
clientBundle,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +212,17 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
it("keeps a line break when a chunk ends in the next line's indent", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
|
||||
harness.send(assistant(" "));
|
||||
const result = harness.send(assistant(" B --> C[Finish]\n```"));
|
||||
expect(result.head).toMatchObject([
|
||||
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
|
||||
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingNewlines;
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
const unrevised = {
|
||||
contentById: new Set<string>(),
|
||||
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
|
||||
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
||||
|
||||
@@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
contentMaxWidth,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
@@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = segments.historyVirtualized[index];
|
||||
return row ? estimateStreamItemHeight(row) : 120;
|
||||
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
rangeExtractor,
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
contentWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
@@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
@@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => {
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses compact estimates for collapsed tool sequence rows", () => {
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
});
|
||||
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
@@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
@@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => {
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow {
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getConfig } from "@expo/config";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("native orientation configuration", () => {
|
||||
const { exp } = getConfig(path.resolve(import.meta.dirname, ".."));
|
||||
|
||||
it("lets Android choose the full display window before runtime orientation policy applies", () => {
|
||||
expect(exp.orientation).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the iPhone portrait orientations explicit while iPad supports rotation", () => {
|
||||
expect(exp.ios?.infoPlist?.UISupportedInterfaceOrientations).toEqual([
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
]);
|
||||
expect(exp.ios?.supportsTablet).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user