mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a134873d47 | ||
|
|
4274b2be0d | ||
|
|
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 | ||
|
|
c481ecf3e1 | ||
|
|
dfc9add774 | ||
|
|
da48803a43 | ||
|
|
7f5d32cdd5 | ||
|
|
dec2d861ec | ||
|
|
30178c4f58 | ||
|
|
52d345db7f | ||
|
|
a50a476009 | ||
|
|
6ca001c3e4 | ||
|
|
78a0f093eb | ||
|
|
d7b7016cc9 |
@@ -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,66 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
|
||||
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
|
||||
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
|
||||
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
|
||||
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
|
||||
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
|
||||
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
|
||||
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
|
||||
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
|
||||
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
|
||||
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
|
||||
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
|
||||
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
|
||||
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
|
||||
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
|
||||
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
|
||||
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
|
||||
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
|
||||
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
+4
-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" section on the Appearance 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/appearance/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 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**.
|
||||
- **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,9 @@ 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`).
|
||||
- **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`).
|
||||
|
||||
+17
-1
@@ -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
|
||||
@@ -376,6 +386,12 @@ 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 buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
|
||||
+6
-15
@@ -195,26 +195,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 optional `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. Mark the window the app should show first with `headline: true`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -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-EU28KEISx1lV1wKSiB3npPER1g+hTMD64JuDWxDjHPE=
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
|
||||
Generated
+84
-51
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -42,13 +43,6 @@
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||
@@ -6340,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
|
||||
@@ -14390,6 +14388,13 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
@@ -33341,6 +33346,23 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
||||
@@ -33515,23 +33537,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -36410,7 +36415,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37476,12 +37481,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/server": "0.10.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37728,10 +37733,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37742,7 +37747,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37986,7 +37991,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38882,7 +38887,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38911,14 +38916,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38928,8 +38933,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38951,7 +38956,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38978,7 +38983,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39196,16 +39201,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/highlight": "0.10.0",
|
||||
"@getpaseo/plugin": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39238,6 +39243,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -40096,6 +40102,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",
|
||||
@@ -40110,7 +40131,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41712,6 +41733,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,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 } });
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,247 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
seedRunningMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
|
||||
return {
|
||||
id: REPORT_ID,
|
||||
account: { label: "Fixture account" },
|
||||
fetchedAt,
|
||||
sourceId: "fixture",
|
||||
sourceLabel: "Fixture plan",
|
||||
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 3 },
|
||||
{ id: "weekly", label: "Weekly", usedPct, headline: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "usage-composer-pill-",
|
||||
title: "Usage composer pill e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("shows the report when an existing running chat opens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "usage-running-",
|
||||
title: "Running usage pill",
|
||||
initialPrompt: "Show usage during the turn.",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toContainText("42%", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
|
||||
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
|
||||
await expect(popover.getByText("Test plan")).toBeVisible();
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes only its report from the popover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: [REPORT_ID] },
|
||||
{ forceRefresh: true, reportIds: [REPORT_ID] },
|
||||
]);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(pill).toContainText("64%");
|
||||
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
|
||||
|
||||
// Pressing closes the tooltip; it reopens on the next hover.
|
||||
await popover.getByText("Fixture plan", { exact: true }).hover();
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
expect(usage.listRequests()).toHaveLength(2);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [
|
||||
[agentEntry(42, twoHoursAgo())],
|
||||
{ error: "Usage source timed out" },
|
||||
() => [agentEntry(64, new Date().toISOString())],
|
||||
],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
const refresh = popover.getByTestId("usage-refresh");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
|
||||
"Unable to refresh usage",
|
||||
);
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
await expect(pill).toContainText("42%");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(3);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("prints the freshness on the card where there is no hover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
const requestsBeforeTurn = usage.agentRequests().length;
|
||||
const listRequestsBeforeTurn = usage.listRequests().length;
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
|
||||
|
||||
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
|
||||
expect(usage.agentRequests().at(-1)).toEqual({
|
||||
agentId: session.agentId,
|
||||
});
|
||||
await expect(pill).toContainText("42%");
|
||||
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden when the agent has no usage report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await usage.waitForAgentRequests(1);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden on a host without usage sources", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSources: false,
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.agentRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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();
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar and groups reports under their 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, headline: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
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 });
|
||||
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, headline: true }],
|
||||
},
|
||||
};
|
||||
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, headline: true }],
|
||||
},
|
||||
});
|
||||
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 sources", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
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, headline: true }],
|
||||
},
|
||||
}),
|
||||
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();
|
||||
});
|
||||
|
||||
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 sources and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
@@ -83,7 +84,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||
const timelineUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "chat");
|
||||
await page.getByRole("switch", { name: "Chat outline" }).click();
|
||||
await page.goto(timelineUrl);
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>>;
|
||||
@@ -47,7 +49,7 @@ async function openPluginScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,8 +20,12 @@ interface SavedSettingsHostInput {
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
@@ -70,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> {
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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[] }>;
|
||||
agentRequests(): Array<{ agentId: string }>;
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
waitForAgentRequests(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)>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
|
||||
usageSources?: 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 withUsageSourcesFeature(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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 agentRequests: Array<{ agentId: string }> = [];
|
||||
const listCounter = createCounter();
|
||||
const agentCounter = createCounter();
|
||||
const usageSources = options.usageSources ?? 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;
|
||||
}
|
||||
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
|
||||
agentRequests.push({
|
||||
agentId: String(request.agentId),
|
||||
});
|
||||
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent.resolve_usage_report.response",
|
||||
payload: { requestId, reportId },
|
||||
},
|
||||
}),
|
||||
);
|
||||
agentCounter.increment();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
agentRequests: () => [...agentRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
waitForAgentRequests: (count) => agentCounter.waitFor(count),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -210,6 +210,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}`;
|
||||
|
||||
@@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/usage" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
@@ -892,6 +893,7 @@ function RootStack() {
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="usage" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { UsageScreen } from "@/usage";
|
||||
|
||||
export default function UsageRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<UsageScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
@@ -127,38 +128,6 @@ export function AssistantMarkdownCodeLink({
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
|
||||
describe("formatFileLinkTooltipPath", () => {
|
||||
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: {
|
||||
path: "C:/Users/me/repo/src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
},
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("src/app.ts:12-20");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/src/app.ts", lineStart: 12 },
|
||||
workspaceRoot: "C:\\",
|
||||
}),
|
||||
).toBe("src/app.ts:12");
|
||||
});
|
||||
|
||||
it("shows the workspace root as a dot", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/repo" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe(".");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
|
||||
workspaceRoot: "c:\\users\\me\\repo",
|
||||
}),
|
||||
).toBe(".:4");
|
||||
});
|
||||
|
||||
it("keeps an absolute path outside the workspace", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/notes.md" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe("/Users/me/notes.md");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/me/notes.md:8");
|
||||
});
|
||||
|
||||
it("keeps the target path when the workspace root is unavailable", () => {
|
||||
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||
"src/app.ts:12",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
isWorkspaceRootPath,
|
||||
resolveWorkspaceFilePaths,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
interface FormatFileLinkTooltipPathInput {
|
||||
target: WorkspaceFileLocation;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function formatFileLinkTooltipPath({
|
||||
target,
|
||||
workspaceRoot,
|
||||
}: FormatFileLinkTooltipPathInput): string {
|
||||
const resolvedPaths = workspaceRoot
|
||||
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||
: null;
|
||||
const isWorkspaceRoot = workspaceRoot
|
||||
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
|
||||
: false;
|
||||
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
@@ -181,7 +181,6 @@ function SessionRow({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
@@ -283,7 +282,7 @@ function SessionRow({
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile />
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
@@ -316,9 +315,7 @@ function SessionRow({
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
|
||||
</View>
|
||||
) : null}
|
||||
<SessionRowTrailingAttention
|
||||
@@ -330,6 +327,15 @@ function SessionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActivityTime({ date, isMobile }: { date: Date; isMobile: boolean }) {
|
||||
const label = useTimeAgo(date);
|
||||
return (
|
||||
<Text style={isMobile ? styles.sessionMetaText : styles.columnMetaFixed} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
agents,
|
||||
isRefreshing = false,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
@@ -13,9 +10,6 @@ interface ContextWindowMeterProps {
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
serverId?: string;
|
||||
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
|
||||
provider?: string | null;
|
||||
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||
pending?: boolean;
|
||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||
@@ -101,30 +95,13 @@ export function ContextWindowMeter({
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
serverId,
|
||||
provider,
|
||||
pending = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
|
||||
serverId ?? null,
|
||||
{ enabled: isTooltipOpen },
|
||||
);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage().catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
|
||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||
@@ -140,7 +117,6 @@ export function ContextWindowMeter({
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -167,13 +143,7 @@ export function ContextWindowMeter({
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
open={isTooltipOpen}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
@@ -187,7 +157,6 @@ export function ContextWindowMeter({
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -209,6 +178,8 @@ export function ContextWindowMeter({
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
@@ -233,7 +204,6 @@ export function ContextWindowMeter({
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -256,9 +226,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
percentageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
@@ -309,7 +309,6 @@ function ImportSessionSheetRow({
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
@@ -344,9 +343,11 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{importing ? t("importSession.row.importing") : lastActivity}
|
||||
</Text>
|
||||
{importing ? (
|
||||
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
|
||||
) : (
|
||||
<ImportSessionActivityTime date={entry.lastActivityAt} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
@@ -365,6 +366,11 @@ function ImportSessionSheetRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
const label = useTimeAgo(new Date(date));
|
||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
serverId,
|
||||
entries,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
buildUsageRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
@@ -426,6 +430,25 @@ function SidebarHostPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const handlePress = useCallback(() => {
|
||||
if (isCompactLayout) showMobileAgent();
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompactLayout, showMobileAgent]);
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={handlePress}
|
||||
testID="sidebar-usage"
|
||||
label={t("settings.hostSections.usage")}
|
||||
icon={Gauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
@@ -489,6 +512,7 @@ function SidebarFooter({
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageButton theme={theme} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface PageLayoutProps {
|
||||
title?: string;
|
||||
/** Compact back button. Defaults to navigating back. */
|
||||
onBack?: () => void;
|
||||
testID?: string;
|
||||
titleTestID?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-width page with a centered content column. On desktop the title is a
|
||||
* document heading inside the page and the header only keeps the titlebar drag
|
||||
* region and window controls; on compact the title moves into a back header.
|
||||
*/
|
||||
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
const showTitle = !isCompact && title !== undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
|
||||
<View style={styles.content}>
|
||||
{showTitle ? (
|
||||
<View testID={titleTestID}>
|
||||
<Text style={styles.title} testID="page-title">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: theme.spacing[4],
|
||||
paddingTop: theme.spacing[6],
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
title: {
|
||||
// Line up with the section titles, which sit inset from their cards.
|
||||
marginLeft: theme.spacing[1],
|
||||
marginBottom: theme.spacing[6],
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
@@ -430,16 +430,10 @@ function renderProviderSheetFooter({
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
const metaStyle = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||
</Text>
|
||||
{fetchedAt || !isCompact ? (
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button
|
||||
@@ -477,6 +471,25 @@ function renderProviderSheetFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUpdatedLabel({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
}: {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
|
||||
const style = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
return (
|
||||
<Text style={style} numberOfLines={1}>
|
||||
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -609,18 +622,6 @@ export function ProviderDiagnosticSheet({
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
if (!providerEntry?.fetchedAt) return null;
|
||||
void clockTick;
|
||||
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||
}, [providerEntry?.fetchedAt, clockTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setQuery("");
|
||||
@@ -686,7 +687,7 @@ export function ProviderDiagnosticSheet({
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
|
||||
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
|
||||
|
||||
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
|
||||
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
|
||||
return createElement(SvgXml, { xml: svg, width: size, height: size, color });
|
||||
}
|
||||
|
||||
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
createElement(SvgXml, {
|
||||
xml: iconSvg,
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
});
|
||||
SvgIcon({ svg: iconSvg, size, color });
|
||||
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
||||
return SvgProviderIcon;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resolveScheduleTitle,
|
||||
scheduleProductName,
|
||||
} from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
@@ -96,16 +96,19 @@ function stateBadge(state: ScheduleDerivedState): {
|
||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||
// was created, when it last ran, and (only while it can still run) when it runs
|
||||
// next. Status lives on the badge, never repeated here.
|
||||
function buildMeta(
|
||||
schedule: ScheduleSummary,
|
||||
state: ScheduleDerivedState,
|
||||
serverName: string | undefined,
|
||||
singleHost: boolean,
|
||||
): string {
|
||||
function buildMeta(input: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
createdAgo: string;
|
||||
lastRunAgo: string;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}): string {
|
||||
const { schedule, state, serverName, singleHost } = input;
|
||||
const parts = [
|
||||
formatCadence(schedule.cadence),
|
||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||
`Created ${input.createdAgo}`,
|
||||
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
|
||||
];
|
||||
if (state === "active") {
|
||||
const next = formatNextRun(schedule.nextRunAt);
|
||||
@@ -119,6 +122,27 @@ function buildMeta(
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function ScheduleMeta({
|
||||
schedule,
|
||||
state,
|
||||
serverName,
|
||||
singleHost,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}) {
|
||||
const createdAgo = useTimeAgo(new Date(schedule.createdAt));
|
||||
const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null);
|
||||
const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost });
|
||||
return (
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({
|
||||
@@ -168,7 +192,6 @@ export function ScheduleRow({
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const productName = scheduleProductName(schedule);
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||
|
||||
const rowStyle = useCallback(
|
||||
@@ -206,9 +229,12 @@ export function ScheduleRow({
|
||||
<Text style={styles.target} numberOfLines={1}>
|
||||
{targetLabel}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
<ScheduleMeta
|
||||
schedule={schedule}
|
||||
state={state}
|
||||
serverName={serverName}
|
||||
singleHost={singleHost ?? false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -117,17 +117,16 @@ export function SettingsSelect<Value extends string>({
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value>) {
|
||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={row.label}
|
||||
accessibilityLabel={`${row.label}: ${selectedLabel}`}
|
||||
>
|
||||
<Text style={styles.value}>
|
||||
{options.find((option) => option.value === value)?.label ?? value}
|
||||
</Text>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{options.map((option) => (
|
||||
@@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
||||
control: { flexShrink: 1, maxWidth: "100%" },
|
||||
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
input: { minWidth: 180 },
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
|
||||
/**
|
||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a workspace-row-height row with no separator, for entries that
|
||||
* "compact": a row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
@@ -52,10 +52,9 @@ export function SidebarHeaderRow({
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.button,
|
||||
variant === "compact" && styles.buttonCompact,
|
||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||
],
|
||||
[isActive, variant],
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -64,7 +63,7 @@ export function SidebarHeaderRow({
|
||||
return (
|
||||
<>
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
@@ -74,7 +73,7 @@ export function SidebarHeaderRow({
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label, shortcutKeys],
|
||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||
// compact header entries sit tight against the workspace list below.
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
// Compact header entries (New workspace / History) sit tighter than the
|
||||
// workspace-row shape the base button mirrors.
|
||||
buttonCompact: {
|
||||
minHeight: 32,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the workspace list below (base button uses a wider spacing[3]).
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
|
||||
@@ -228,22 +228,22 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonXs: {
|
||||
minHeight: buttonControlHeight.xs,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonSm: {
|
||||
minHeight: buttonControlHeight.sm,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonMd: {
|
||||
minHeight: buttonControlHeight.md,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonLg: {
|
||||
minHeight: buttonControlHeight.lg,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -306,17 +306,17 @@ export function createControlGeometry(theme: Theme) {
|
||||
segmentedSegmentXs: {
|
||||
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
type ButtonControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
|
||||
children?: ReactNode;
|
||||
chevron?: ReactNode | null;
|
||||
}
|
||||
|
||||
const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children" | "style"> {
|
||||
/** The selected option's label. */
|
||||
children: string;
|
||||
/** Icon or swatch before the label. */
|
||||
leading?: ReactNode;
|
||||
size?: ButtonControlSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* A select-style menu trigger: the selected value plus a chevron, drawn with the
|
||||
* outline Button's chrome and size scale so a dropdown and a button in the same
|
||||
* row share one geometry. Callers pick a size; they don't style it.
|
||||
*/
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
chevron,
|
||||
leading,
|
||||
size = "sm",
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "md") {
|
||||
sizeStyle = styles.md;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
} else {
|
||||
sizeStyle = styles.sm;
|
||||
}
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
],
|
||||
[disabled, sizeStyle],
|
||||
);
|
||||
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
|
||||
|
||||
return (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
<View style={styles.row}>
|
||||
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
|
||||
{leading}
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{children}
|
||||
{chevron !== null &&
|
||||
(chevron ?? (
|
||||
<View style={styles.chevronContainer}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
))}
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
chevronContainer: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
base: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
xs: geometry.buttonXs,
|
||||
sm: geometry.buttonSm,
|
||||
md: geometry.buttonMd,
|
||||
lg: geometry.buttonLg,
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
...geometry.buttonText,
|
||||
},
|
||||
labelXs: geometry.buttonTextXs,
|
||||
chevron: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +56,8 @@ interface ToolbarButtonCommonProps extends Omit<
|
||||
> {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
/** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */
|
||||
tooltip?: ReactNode;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
shortcut?: ShortcutKey[][] | null;
|
||||
@@ -76,6 +78,7 @@ type ToolbarButtonProps = ToolbarButtonCommonProps &
|
||||
export function ToolbarButton({
|
||||
children,
|
||||
label,
|
||||
tooltip: tooltipBody,
|
||||
selected = false,
|
||||
compact = false,
|
||||
shortcut,
|
||||
@@ -105,7 +108,7 @@ export function ToolbarButton({
|
||||
const tooltip = (
|
||||
<TooltipContent side={tooltipSide} align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{tooltipBody ?? <Text style={styles.tooltipText}>{label}</Text>}
|
||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -32,15 +32,32 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseColor(value: string): { rgb: string; alpha: number } {
|
||||
const channels = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 };
|
||||
}
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(["success", "warning", "error", "muted"] as const)(
|
||||
"uses the semantic badge shell for the %s variant",
|
||||
it("uses the neutral badge shell for the muted variant", () => {
|
||||
const style = getComputedStyle(mountBadge("muted"));
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
});
|
||||
|
||||
it.each(["success", "warning", "error"] as const)(
|
||||
"fills the %s variant with a translucent tint of its own status color",
|
||||
(variant) => {
|
||||
const badge = mountBadge(variant);
|
||||
const style = getComputedStyle(badge);
|
||||
const text = badge.lastElementChild;
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render its label");
|
||||
}
|
||||
const fill = parseColor(getComputedStyle(badge).backgroundColor);
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
|
||||
expect(fill.alpha).toBeGreaterThan(0);
|
||||
expect(fill.alpha).toBeLessThan(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "warning" && styles.pillWarning,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.pillText,
|
||||
@@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.pill}>
|
||||
<View style={pillStyle}>
|
||||
{leading}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</View>
|
||||
@@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.statusSuccessTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillWarning: {
|
||||
backgroundColor: theme.colors.statusWarningTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.statusDangerTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
commandDraft={composerState?.commandDraft}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
|
||||
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
expect(getLatest().composerState?.commandDraft).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
describe("buildDraftCommandTarget", () => {
|
||||
const codexSelection = {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
};
|
||||
|
||||
it("needs a project when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
cwd: " ",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toEqual({ status: "needs-project" });
|
||||
});
|
||||
|
||||
it("needs a provider when none is selected", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: { ...codexSelection, provider: null },
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "",
|
||||
effectiveThinkingOptionId: "",
|
||||
}),
|
||||
).toEqual({ status: "needs-provider" });
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type DraftKeyInput,
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
|
||||
export interface AgentInputDraft {
|
||||
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[applyProfileFeatureValues, formState],
|
||||
);
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
const commandDraft = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
})
|
||||
: undefined,
|
||||
buildDraftCommandTarget({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
}),
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
};
|
||||
}, [
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -358,7 +355,6 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
buildDraftAgentControls,
|
||||
};
|
||||
|
||||
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type DraftAgentControlsProps,
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { UsageComposerPill } from "@/usage";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
@@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
||||
model: agent?.model ?? null,
|
||||
provider: agent?.provider ?? null,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -284,8 +284,6 @@ function renderContextWindowMeter(
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
pending: boolean,
|
||||
glyphSize: number,
|
||||
): ReactElement | null {
|
||||
@@ -299,8 +297,6 @@ function renderContextWindowMeter(
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
pending={pending}
|
||||
glyphSize={glyphSize}
|
||||
/>
|
||||
@@ -977,8 +973,8 @@ interface ComposerProps {
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig;
|
||||
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
|
||||
commandDraft?: DraftCommandTarget;
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void;
|
||||
onComposerHeightChange?: (height: number) => void;
|
||||
@@ -1268,7 +1264,7 @@ function ComposerContentImpl({
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
@@ -2085,8 +2081,6 @@ function ComposerContentImpl({
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
false,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
@@ -2094,15 +2088,18 @@ function ComposerContentImpl({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
],
|
||||
);
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
|
||||
[contextWindowMeter, hasAgent],
|
||||
() => (
|
||||
<>
|
||||
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
|
||||
{hasAgent ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
|
||||
</>
|
||||
),
|
||||
[agentId, contextWindowMeter, hasAgent, serverId],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
@@ -2349,7 +2346,7 @@ function ComposerContentImpl({
|
||||
setUserInput: replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: commandDraftConfig,
|
||||
draft: commandDraft,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
@@ -2358,7 +2355,7 @@ function ComposerContentImpl({
|
||||
replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
buildOutgoingAttachments,
|
||||
attachments,
|
||||
runClientSlashCommand,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
@@ -318,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
serverId,
|
||||
@@ -568,16 +551,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
if (serverInfo) {
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { CommitGraphNode } from "./commit-graph-node";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
@@ -26,7 +25,6 @@ export const CommitRow = memo(function CommitRow({
|
||||
commit,
|
||||
isFirst,
|
||||
isLast,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -49,7 +47,7 @@ export const CommitRow = memo(function CommitRow({
|
||||
{commit.subject}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<CommitTimestamp authorDate={commit.authorDate} />
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
@@ -57,6 +55,11 @@ export const CommitRow = memo(function CommitRow({
|
||||
);
|
||||
});
|
||||
|
||||
function CommitTimestamp({ authorDate }: { authorDate: string }) {
|
||||
const label = useTimeAgo(new Date(authorDate));
|
||||
return <Text style={styles.timestamp}>{label}</Text>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
@@ -40,11 +39,9 @@ function CommitsSectionSkeleton() {
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -77,7 +74,6 @@ function CommitsSectionContent({
|
||||
commit={commit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === workspaceCommits.length - 1}
|
||||
now={now}
|
||||
onCommitPress={onCommitPress}
|
||||
/>
|
||||
))}
|
||||
@@ -94,9 +90,6 @@ export function CommitsSection({
|
||||
}: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -104,20 +97,9 @@ export function CommitsSection({
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setNow(new Date());
|
||||
}
|
||||
onCollapsedChange?.(!collapsed);
|
||||
}, [collapsed, onCollapsedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !isPanelActive) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [collapsed, isPanelActive]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
@@ -160,9 +142,7 @@ export function CommitsSection({
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{collapsed ? null : (
|
||||
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||
)}
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
@@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput {
|
||||
setUserInput: (nextValue: string) => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
draft?: DraftCommandTarget;
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
@@ -117,18 +118,13 @@ type AvailableCommand =
|
||||
| { source: "plugin"; command: PluginClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig,
|
||||
): DraftCommandConfig | undefined {
|
||||
if (!draftConfig) {
|
||||
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
|
||||
if (draft?.status !== "ready") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const draftConfig = draft.config;
|
||||
const cwd = draftConfig.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
@@ -337,6 +333,35 @@ function resolveAutocompleteErrorMessage(args: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
||||
// commands by its config only, and not at all until that config is complete.
|
||||
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
||||
const queryDraftConfig = resolveDraftQueryConfig(draft);
|
||||
const isDraftContext = draft !== undefined;
|
||||
return {
|
||||
isDraftContext,
|
||||
queryDraftConfig,
|
||||
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAutocompleteEmptyText(args: {
|
||||
mode: AutocompleteMode;
|
||||
draft: DraftCommandTarget | undefined;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (args.mode === "file") {
|
||||
return args.t("agentAutocomplete.noFiles");
|
||||
}
|
||||
if (args.draft?.status === "needs-project") {
|
||||
return args.t("agentAutocomplete.chooseProjectForCommands");
|
||||
}
|
||||
if (args.draft?.status === "needs-provider") {
|
||||
return args.t("agentAutocomplete.chooseModelForCommands");
|
||||
}
|
||||
return args.t("agentAutocomplete.noCommands");
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig,
|
||||
draft,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
@@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
return () => clearTimeout(timer);
|
||||
}, [fileFilterQuery]);
|
||||
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
[draftConfig],
|
||||
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
|
||||
() => resolveDraftCommandContext(draft),
|
||||
[draft],
|
||||
);
|
||||
|
||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useSessionStore(
|
||||
@@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const {
|
||||
commands,
|
||||
isLoading: isCommandsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useAgentCommandsQuery({
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
const { commands, isError, error } = commandsQuery;
|
||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
||||
|
||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||
|
||||
@@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
mode === "file"
|
||||
? t("agentAutocomplete.searchingWorkspace")
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
const emptyText =
|
||||
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
|
||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface AgentSlashCommand {
|
||||
|
||||
export type DraftCommandConfig = AgentCommandsDraftConfig;
|
||||
|
||||
/** What a draft composer can list commands for, before an agent exists. */
|
||||
export type DraftCommandTarget =
|
||||
| { status: "ready"; config: DraftCommandConfig }
|
||||
| { status: "needs-project" }
|
||||
| { status: "needs-provider" };
|
||||
|
||||
interface ListAgentCommandsOptions {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
|
||||
+17
-5
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
||||
import { describeCompactTimeAgo } from "@/utils/time";
|
||||
import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time";
|
||||
|
||||
/** A prose relative timestamp ("5m ago") that keeps itself current. See `useCompactTimeAgo`. */
|
||||
export function useTimeAgo(date: Date | null): string {
|
||||
return useRelativeTimeLabel(date, describeTimeAgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact relative timestamp that keeps itself current.
|
||||
@@ -19,7 +24,14 @@ import { describeCompactTimeAgo } from "@/utils/time";
|
||||
* never changes again.
|
||||
*/
|
||||
export function useCompactTimeAgo(date: Date | null): string {
|
||||
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
|
||||
return useRelativeTimeLabel(date, describeCompactTimeAgo);
|
||||
}
|
||||
|
||||
function useRelativeTimeLabel(
|
||||
date: Date | null,
|
||||
describe: (date: Date) => RelativeTimeLabel,
|
||||
): string {
|
||||
const [label, setLabel] = useState(() => (date ? describe(date).label : ""));
|
||||
|
||||
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
|
||||
// depending on identity would tear down and rebuild the subscription for an unchanged time.
|
||||
@@ -32,13 +44,13 @@ export function useCompactTimeAgo(date: Date | null): string {
|
||||
}
|
||||
|
||||
const source = new Date(time);
|
||||
let current = describeCompactTimeAgo(source);
|
||||
let current = describe(source);
|
||||
setLabel(current.label);
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleTick = () => {
|
||||
const next = describeCompactTimeAgo(source);
|
||||
const next = describe(source);
|
||||
if (next.label !== current.label) {
|
||||
setLabel(next.label);
|
||||
}
|
||||
@@ -62,7 +74,7 @@ export function useCompactTimeAgo(date: Date | null): string {
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [time]);
|
||||
}, [time, describe]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -1522,6 +1522,8 @@ export const ar: TranslationResources = {
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "تعذر تحميل السجل الأقدم",
|
||||
@@ -1953,8 +1955,11 @@ export const ar: TranslationResources = {
|
||||
groupInfo: "حول{{title}}",
|
||||
sections: {
|
||||
general: "عام",
|
||||
chat: "الدردشة",
|
||||
appearance: "مظهر",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "الشريط الجانبي",
|
||||
terminal: "الطرفية",
|
||||
browser: "المتصفح",
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
@@ -2013,6 +2018,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
browserData: {
|
||||
title: "بيانات المتصفح",
|
||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
@@ -2040,8 +2046,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "عناوين URL للخدمة",
|
||||
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
|
||||
options: {
|
||||
ask: "بسأل",
|
||||
inApp: "في Paseo",
|
||||
@@ -2060,7 +2064,6 @@ export const ar: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
|
||||
@@ -1547,6 +1547,8 @@ export const en = {
|
||||
noFiles: "No files or directories found",
|
||||
noCommands: "No commands found",
|
||||
failedToLoad: "Failed to load",
|
||||
chooseProjectForCommands: "Choose a project to see commands",
|
||||
chooseModelForCommands: "Select a model to see commands",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Couldn't load older history",
|
||||
@@ -1978,9 +1980,12 @@ export const en = {
|
||||
groupInfo: "About {{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
layout: "Layout",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
notifications: "Notifications",
|
||||
@@ -1998,29 +2003,32 @@ export const en = {
|
||||
},
|
||||
sources: {
|
||||
explorerFiles: {
|
||||
label: "Selecting a file in Explorer",
|
||||
label: "Clicking a file in the Explorer sidebar",
|
||||
description: "Open files selected in the Explorer sidebar beside your work",
|
||||
},
|
||||
diffs: {
|
||||
label: "Opening a diff",
|
||||
label: "Clicking a change in the Explorer sidebar or a chat",
|
||||
description: "Open diffs from Explorer and agent conversations beside your work",
|
||||
},
|
||||
chatFiles: {
|
||||
label: "Opening a file from an agent chat",
|
||||
label: "Clicking a file in an agent chat",
|
||||
description: "Open file links and tool-call files beside the conversation",
|
||||
},
|
||||
diffFiles: {
|
||||
label: "Opening a file from Changes",
|
||||
label: "Clicking a file in a diff",
|
||||
description: "Open source files selected from a diff beside it",
|
||||
},
|
||||
subagents: {
|
||||
label: "Opening a subagent",
|
||||
label: "Clicking a subagent in an agent chat",
|
||||
description: "Open subagents beside their parent agent",
|
||||
},
|
||||
pullRequests: {
|
||||
label: "Opening a pull request from Changes",
|
||||
label: "Clicking a pull request in the Explorer sidebar",
|
||||
description: "Open pull request details beside Changes",
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Clicking a script's service URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2063,6 +2071,7 @@ export const en = {
|
||||
offline: "Connect to this host to open plugin settings.",
|
||||
update: "Update this host to use plugin settings.",
|
||||
unavailable: "This plugin settings screen is unavailable.",
|
||||
backToPlugins: "Back to plugins",
|
||||
},
|
||||
trustedTitle: "Plugins are trusted code",
|
||||
trustedDescription:
|
||||
@@ -2134,6 +2143,7 @@ export const en = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
browserData: {
|
||||
title: "Browser data",
|
||||
siteData: "Cookies and site data",
|
||||
@@ -2160,8 +2170,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Service URLs",
|
||||
description: "Where to open URLs from running scripts",
|
||||
options: {
|
||||
ask: "Ask",
|
||||
inApp: "In Paseo",
|
||||
@@ -2180,7 +2188,6 @@ export const en = {
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
accessibilityLabel: "Select tool call display ({{value}})",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const es: TranslationResources = {
|
||||
noFiles: "No se encontraron archivos ni directorios",
|
||||
noCommands: "No se encontraron comandos",
|
||||
failedToLoad: "No se pudo cargar",
|
||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "No se pudo cargar el historial anterior",
|
||||
@@ -2001,8 +2003,11 @@ export const es: TranslationResources = {
|
||||
groupInfo: "Acerca de{{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Apariencia",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
@@ -2061,6 +2066,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
browserData: {
|
||||
title: "Datos del navegador",
|
||||
siteData: "Cookies y datos de sitios",
|
||||
@@ -2091,8 +2097,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de servicio",
|
||||
description: "Dónde abrir URL desde scripts en ejecución",
|
||||
options: {
|
||||
ask: "Preguntar",
|
||||
inApp: "EnPaseo",
|
||||
@@ -2112,7 +2116,6 @@ export const es: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const fr: TranslationResources = {
|
||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||
noCommands: "Aucune commande trouvée",
|
||||
failedToLoad: "Échec du chargement",
|
||||
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
|
||||
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Impossible de charger l'ancien historique",
|
||||
@@ -2006,8 +2008,11 @@ export const fr: TranslationResources = {
|
||||
groupInfo: "À propos de{{title}}",
|
||||
sections: {
|
||||
general: "Général",
|
||||
chat: "Discussion",
|
||||
appearance: "Apparence",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barre latérale",
|
||||
terminal: "Terminal",
|
||||
browser: "Navigateur",
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
@@ -2066,6 +2071,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
browserData: {
|
||||
title: "Données du navigateur",
|
||||
siteData: "Cookies et données des sites",
|
||||
@@ -2095,8 +2101,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de services",
|
||||
description: "Où ouvrir les URL à partir de scripts en cours d'exécution",
|
||||
options: {
|
||||
ask: "Demander",
|
||||
inApp: "DansPaseo",
|
||||
@@ -2115,7 +2119,6 @@ export const fr: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
|
||||
@@ -1539,6 +1539,8 @@ export const ja: TranslationResources = {
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "古い履歴を読み込めませんでした",
|
||||
@@ -1973,8 +1975,11 @@ export const ja: TranslationResources = {
|
||||
groupInfo: "{{title}}について",
|
||||
sections: {
|
||||
general: "一般",
|
||||
chat: "チャット",
|
||||
appearance: "外観",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "サイドバー",
|
||||
terminal: "ターミナル",
|
||||
browser: "ブラウザ",
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
@@ -2033,6 +2038,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
browserData: {
|
||||
title: "ブラウザーデータ",
|
||||
siteData: "Cookie とサイトデータ",
|
||||
@@ -2059,8 +2065,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "サービスURL",
|
||||
description: "実行中のスクリプトからURLを開く場所",
|
||||
options: {
|
||||
ask: "確認する",
|
||||
inApp: "Paseoで",
|
||||
@@ -2079,7 +2083,6 @@ export const ja: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
|
||||
options: {
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
|
||||
@@ -1532,6 +1532,8 @@ export const ko: TranslationResources = {
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "이전 기록을 불러올 수 없습니다",
|
||||
@@ -1963,8 +1965,11 @@ export const ko: TranslationResources = {
|
||||
groupInfo: "{{title}} 정보",
|
||||
sections: {
|
||||
general: "일반",
|
||||
chat: "채팅",
|
||||
appearance: "모양",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "사이드바",
|
||||
terminal: "터미널",
|
||||
browser: "브라우저",
|
||||
editor: "편집기",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
@@ -2023,6 +2028,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
browserData: {
|
||||
title: "브라우저 데이터",
|
||||
siteData: "쿠키 및 사이트 데이터",
|
||||
@@ -2051,8 +2057,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "서비스 URL",
|
||||
description: "실행 중인 스크립트의 URL을 열 위치",
|
||||
options: {
|
||||
ask: "물어보기",
|
||||
inApp: "Paseo에서",
|
||||
@@ -2071,7 +2075,6 @@ export const ko: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
accessibilityLabel: "도구 호출 표시 선택({{value}})",
|
||||
options: {
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const pluginSettings = {
|
||||
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
|
||||
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
|
||||
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
|
||||
backToPlugins: "العودة إلى الإضافات",
|
||||
},
|
||||
title: "الإضافات",
|
||||
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
|
||||
@@ -72,6 +73,7 @@ export const pluginSettings = {
|
||||
offline: "Conéctate a este host para abrir los ajustes del plugin.",
|
||||
update: "Actualiza este host para usar los ajustes del plugin.",
|
||||
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
|
||||
backToPlugins: "Volver a plugins",
|
||||
},
|
||||
title: "Plugins",
|
||||
trustedTitle: "Los plugins son código de confianza",
|
||||
@@ -126,6 +128,7 @@ export const pluginSettings = {
|
||||
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
|
||||
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
|
||||
unavailable: "Cet écran de paramètres du plugin est indisponible.",
|
||||
backToPlugins: "Retour aux plugins",
|
||||
},
|
||||
title: "Plugins",
|
||||
trustedTitle: "Les plugins sont du code de confiance",
|
||||
@@ -180,6 +183,7 @@ export const pluginSettings = {
|
||||
offline: "このホストに接続してプラグイン設定を開いてください。",
|
||||
update: "プラグイン設定を使用するには、このホストを更新してください。",
|
||||
unavailable: "このプラグイン設定画面は利用できません。",
|
||||
backToPlugins: "プラグインに戻る",
|
||||
},
|
||||
title: "プラグイン",
|
||||
trustedTitle: "プラグインは信頼済みコードです",
|
||||
@@ -234,6 +238,7 @@ export const pluginSettings = {
|
||||
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
|
||||
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
|
||||
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
|
||||
backToPlugins: "플러그인으로 돌아가기",
|
||||
},
|
||||
title: "플러그인",
|
||||
trustedTitle: "플러그인은 신뢰하는 코드입니다",
|
||||
@@ -287,6 +292,7 @@ export const pluginSettings = {
|
||||
offline: "Conecte-se a este host para abrir as configurações do plugin.",
|
||||
update: "Atualize este host para usar as configurações do plugin.",
|
||||
unavailable: "Esta tela de configurações do plugin está indisponível.",
|
||||
backToPlugins: "Voltar para plugins",
|
||||
},
|
||||
title: "Plugins",
|
||||
trustedTitle: "Plugins são código confiável",
|
||||
@@ -341,6 +347,7 @@ export const pluginSettings = {
|
||||
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
|
||||
update: "Обновите этот хост, чтобы использовать настройки плагина.",
|
||||
unavailable: "Этот экран настроек плагина недоступен.",
|
||||
backToPlugins: "Вернуться к плагинам",
|
||||
},
|
||||
title: "Плагины",
|
||||
trustedTitle: "Плагины — доверенный код",
|
||||
@@ -395,6 +402,7 @@ export const pluginSettings = {
|
||||
offline: "连接到此主机以打开插件设置。",
|
||||
update: "更新此主机以使用插件设置。",
|
||||
unavailable: "此插件设置页面不可用。",
|
||||
backToPlugins: "返回插件",
|
||||
},
|
||||
title: "插件",
|
||||
trustedTitle: "插件是受信任的代码",
|
||||
|
||||
@@ -1553,6 +1553,8 @@ export const ptBR: TranslationResources = {
|
||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||
noCommands: "Nenhum comando encontrado",
|
||||
failedToLoad: "Falha ao carregar",
|
||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Não foi possível carregar o histórico mais antigo",
|
||||
@@ -1986,8 +1988,11 @@ export const ptBR: TranslationResources = {
|
||||
groupInfo: "Sobre {{title}}",
|
||||
sections: {
|
||||
general: "Geral",
|
||||
chat: "Chat",
|
||||
appearance: "Aparência",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
@@ -2046,6 +2051,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
browserData: {
|
||||
title: "Dados do navegador",
|
||||
siteData: "Cookies e dados de sites",
|
||||
@@ -2074,8 +2080,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URLs de serviço",
|
||||
description: "Onde abrir URLs de scripts em execução",
|
||||
options: {
|
||||
ask: "Perguntar",
|
||||
inApp: "No Paseo",
|
||||
@@ -2095,7 +2099,6 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
|
||||
@@ -1551,6 +1551,8 @@ export const ru: TranslationResources = {
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Не удалось загрузить старую историю.",
|
||||
@@ -1986,8 +1988,11 @@ export const ru: TranslationResources = {
|
||||
groupInfo: "О разделе «{{title}}»",
|
||||
sections: {
|
||||
general: "Основные",
|
||||
chat: "Чат",
|
||||
appearance: "Оформление",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Боковая панель",
|
||||
terminal: "Терминал",
|
||||
browser: "Браузер",
|
||||
editor: "Редактор",
|
||||
shortcuts: "Сочетания клавиш",
|
||||
integrations: "Интеграции",
|
||||
@@ -2046,6 +2051,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
browserData: {
|
||||
title: "Данные браузера",
|
||||
siteData: "Файлы cookie и данные сайтов",
|
||||
@@ -2075,8 +2081,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL-адреса сервисов",
|
||||
description: "Где открывать URL-адреса запущенных скриптов",
|
||||
options: {
|
||||
ask: "Спрашивать",
|
||||
inApp: "В Paseo",
|
||||
@@ -2096,7 +2100,6 @@ export const ru: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
|
||||
options: {
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
|
||||
@@ -1505,6 +1505,8 @@ export const zhCN: TranslationResources = {
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "无法加载更早历史",
|
||||
@@ -1932,8 +1934,11 @@ export const zhCN: TranslationResources = {
|
||||
groupInfo: "关于 {{title}}",
|
||||
sections: {
|
||||
general: "通用",
|
||||
chat: "聊天",
|
||||
appearance: "外观",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "侧边栏",
|
||||
terminal: "终端",
|
||||
browser: "浏览器",
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
@@ -1991,6 +1996,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
browserData: {
|
||||
title: "浏览器数据",
|
||||
siteData: "Cookie 和网站数据",
|
||||
@@ -2016,8 +2022,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "服务 URL",
|
||||
description: "运行脚本中的 URL 打开位置",
|
||||
options: {
|
||||
ask: "询问",
|
||||
inApp: "在 Paseo 中",
|
||||
@@ -2036,7 +2040,6 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用显示方式({{value}})",
|
||||
options: {
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
@@ -127,7 +128,30 @@ function SettingsContent({
|
||||
}
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginSettingsContent(props: SettingsIdentity) {
|
||||
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
|
||||
export function PluginSettingsContent({
|
||||
onBackToPlugins,
|
||||
showBackToPlugins,
|
||||
...identity
|
||||
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
{showBackToPlugins ? (
|
||||
<Button
|
||||
onPress={onBackToPlugins}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backButton}
|
||||
>
|
||||
{t("settings.plugins.screens.backToPlugins")}
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
message: { color: theme.colors.foregroundMuted },
|
||||
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { filterSelectableModels } from "./model-catalog";
|
||||
@@ -286,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId(
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
export function buildDraftCommandTarget(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
}): DraftCommandTarget {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
if (!cwd) {
|
||||
return { status: "needs-project" };
|
||||
}
|
||||
if (!input.selection.provider) {
|
||||
return { status: "needs-provider" };
|
||||
}
|
||||
|
||||
return {
|
||||
const config = {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
@@ -310,6 +313,7 @@ export function buildDraftCommandConfig(input: {
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
return { status: "ready", config };
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||
import { formatAgo } from "./format";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { ProviderUsageWindowBar } from "./window-bar";
|
||||
|
||||
interface ProviderUsageIconProps {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||
|
||||
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function statusText(usage: ProviderUsage): string | null {
|
||||
if (usage.status === "available") return null;
|
||||
return usage.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function footerText(usage: ProviderUsage): string | null {
|
||||
const updated = formatAgo(usage.fetchedAt);
|
||||
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function ProviderUsageCard({
|
||||
usage,
|
||||
compact = false,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const status = statusText(usage);
|
||||
const footer = footerText(usage);
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
[compact],
|
||||
);
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
usage.status === "available" && styles.statusDotAvailable,
|
||||
usage.status === "error" && styles.statusDotError,
|
||||
],
|
||||
[usage.status],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.header}>
|
||||
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{usage.displayName}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<View style={styles.details}>
|
||||
{details.map((detail) => (
|
||||
<View key={detail.id} style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{detail.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<Text style={styles.footer} numberOfLines={1}>
|
||||
{footer}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
containerPadded: {
|
||||
gap: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
containerCompact: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
name: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDotAvailable: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
statusDotError: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
bars: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
detailLabel: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
detailValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
footer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -1,18 +0,0 @@
|
||||
// User-facing copy for the provider-usage surfaces, centralized so localization
|
||||
// is a single-file change. INTEGRATION: move these into the i18n resources
|
||||
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
|
||||
// call sites once the feature is wired to data. Kept inline for now because the
|
||||
// surfaces are not yet mounted and the locale files are being edited elsewhere.
|
||||
export const providerUsageCopy = {
|
||||
title: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to this host to see provider usage",
|
||||
hostUpgradeRequired: "Update the host to see provider usage",
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
tooltipLoading: "Loading plan usage…",
|
||||
} as const;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import { ProviderUsageList } from "./list";
|
||||
import type { ProviderUsageView } from "./types";
|
||||
|
||||
export function ProviderUsageSettingsSection({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={RefreshCw}
|
||||
loading={busy}
|
||||
onPress={onRefresh}
|
||||
accessibilityLabel={providerUsageCopy.refresh}
|
||||
>
|
||||
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
|
||||
</Button>
|
||||
),
|
||||
[busy, onRefresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={providerUsageCopy.title}
|
||||
testID="provider-usage-card"
|
||||
trailing={refreshButton}
|
||||
>
|
||||
<ProviderUsageBody view={view} onRefresh={onRefresh} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUsageBody({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={providerUsageCopy.errorTitle}
|
||||
description={view.message}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.payload.providers.length === 0) {
|
||||
return (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProviderUsageList providers={view.payload.providers} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsage, ProviderUsageView } from "./types";
|
||||
|
||||
function matchProvider(
|
||||
providers: ProviderUsage[],
|
||||
activeProviderId: string | null | undefined,
|
||||
): ProviderUsage | null {
|
||||
if (!activeProviderId) return null;
|
||||
const target = activeProviderId.toLowerCase();
|
||||
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
|
||||
}
|
||||
|
||||
// Renders the active agent's provider usage inside the context-meter tooltip.
|
||||
// Returns nothing when the active provider has no usage entry, so the meter's
|
||||
// own context section stays the whole tooltip.
|
||||
export function ProviderUsageTooltipSection({
|
||||
view,
|
||||
activeProviderId,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
activeProviderId: string | null | undefined;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.error}>{view.message}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const usage = matchProvider(view.payload.providers, activeProviderId);
|
||||
if (!usage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<ProviderUsageCard usage={usage} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: 1,
|
||||
// Same token the popover draws its own outline with, so the rule reads as the
|
||||
// popover's edge. `border` is invisible here (equals the popover background).
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
marginVertical: theme.spacing[2],
|
||||
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,26 +0,0 @@
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
};
|
||||
|
||||
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
|
||||
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
|
||||
export type ProviderUsageView =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user