mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5224122a | ||
|
|
af27e28054 |
@@ -34,7 +34,6 @@ hub:
|
|||||||
- "packages/server/src/server/hub/**"
|
- "packages/server/src/server/hub/**"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
- "plugins/**"
|
|
||||||
- "packages/server/**"
|
- "packages/server/**"
|
||||||
- "packages/app/e2e/support/fixtures/recording.*"
|
- "packages/app/e2e/support/fixtures/recording.*"
|
||||||
|
|
||||||
|
|||||||
@@ -117,15 +117,9 @@ jobs:
|
|||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
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
|
- name: Typecheck all packages
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run website tests
|
|
||||||
run: npx vitest run packages/website/src --bail=1
|
|
||||||
|
|
||||||
- name: Install website test browsers
|
- name: Install website test browsers
|
||||||
run: npx playwright install --with-deps chromium webkit
|
run: npx playwright install --with-deps chromium webkit
|
||||||
|
|
||||||
@@ -175,10 +169,6 @@ jobs:
|
|||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_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
|
- name: Run Hub CLI/daemon contract
|
||||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
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
|
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||||
|
|||||||
@@ -1,66 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## 0.9.2 - 2026-09-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-6
@@ -42,15 +42,11 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
|||||||
|
|
||||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||||
|
|
||||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
|
||||||
|
|
||||||
## Local daemon trust boundary
|
## Local daemon trust boundary
|
||||||
|
|
||||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||||
|
|
||||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||||
|
|
||||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
|
||||||
|
|
||||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ $PASEO_HOME/
|
|||||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||||
├── paseo.pid # Daemon PID lock file
|
├── paseo.pid # Daemon PID lock file
|
||||||
├── local-credential # Per-run local client credential (mode 0600)
|
|
||||||
├── daemon.log # Default log file (path configurable)
|
├── daemon.log # Default log file (path configurable)
|
||||||
├── agents/
|
├── agents/
|
||||||
│ └── {sanitized-cwd}/
|
│ └── {sanitized-cwd}/
|
||||||
@@ -558,7 +557,6 @@ These small files are not validated as full Zod schemas but are persisted under
|
|||||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
|
||||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-4
@@ -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.
|
- **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.
|
- **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.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
- **Sidebar items** — The 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**.
|
||||||
- **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).
|
- **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".
|
- **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).
|
- **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,9 +31,6 @@ 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.
|
- **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.
|
- **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`).
|
- **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`).
|
- **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`).
|
- **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`).
|
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||||
|
|||||||
+1
-17
@@ -36,8 +36,7 @@ The daemon stores directory sources under the root `plugins` object:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
|
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
|
||||||
Changing that root field is
|
|
||||||
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
|
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
|
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.
|
remain lifecycle-owned and do not reload from manual config edits.
|
||||||
@@ -86,15 +85,6 @@ 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
|
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.
|
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
|
## Install a Git source
|
||||||
|
|
||||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||||
@@ -386,12 +376,6 @@ 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
|
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||||
SVG or URL.
|
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
|
## Contribute buttons
|
||||||
|
|
||||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ Two contracts follow from it.
|
|||||||
|
|
||||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||||
|
|
||||||
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
|
||||||
|
|
||||||
- New fields are `.optional()` with a sensible default.
|
- New fields are `.optional()` with a sensible default.
|
||||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||||
|
|||||||
+15
-8
@@ -79,8 +79,6 @@ A provider that can register runtime tools directly should set `supportsNativePa
|
|||||||
|
|
||||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||||
|
|
||||||
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
|
|
||||||
|
|
||||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||||
|
|
||||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||||
@@ -195,17 +193,26 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage sources
|
## Provider Usage Fetchers
|
||||||
|
|
||||||
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`.
|
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.
|
||||||
|
|
||||||
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).
|
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:
|
||||||
|
|
||||||
`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`.
|
- `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
|
||||||
|
|
||||||
### Credentials are read only
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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,7 +0,0 @@
|
|||||||
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,7 +0,0 @@
|
|||||||
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,7 +0,0 @@
|
|||||||
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,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/knip/schema.json",
|
"$schema": "./node_modules/knip/schema.json",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"plugins": {
|
|
||||||
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
|
|
||||||
"project": ["**/*.{ts,tsx}"]
|
|
||||||
},
|
|
||||||
".": {
|
".": {
|
||||||
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||||
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
|
||||||
|
|||||||
Generated
+51
-84
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"plugins",
|
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
"packages/highlight",
|
"packages/highlight",
|
||||||
"packages/plugin",
|
"packages/plugin",
|
||||||
@@ -43,6 +42,13 @@
|
|||||||
"ws": "^8.20.0"
|
"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": {
|
"node_modules/@0no-co/graphql.web": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||||
@@ -6334,10 +6340,6 @@
|
|||||||
"resolved": "packages/app",
|
"resolved": "packages/app",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@getpaseo/builtin-plugins": {
|
|
||||||
"resolved": "plugins",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@getpaseo/cli": {
|
"node_modules/@getpaseo/cli": {
|
||||||
"resolved": "packages/cli",
|
"resolved": "packages/cli",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -14388,13 +14390,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/abab": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||||
@@ -33346,23 +33341,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
||||||
@@ -33537,6 +33515,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -36415,7 +36410,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37481,12 +37476,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.9.2",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.9.2",
|
||||||
"@getpaseo/server": "0.10.0",
|
"@getpaseo/server": "0.9.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37733,10 +37728,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.9.2",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.9.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37747,7 +37742,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37991,7 +37986,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38887,7 +38882,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38916,14 +38911,14 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.9.2",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.9.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38933,8 +38928,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.9.2",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.9.2",
|
||||||
"react": "~19.1.0",
|
"react": "~19.1.0",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38956,7 +38951,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38983,7 +38978,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39201,16 +39196,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.9.2",
|
||||||
"@getpaseo/highlight": "0.10.0",
|
"@getpaseo/highlight": "0.9.2",
|
||||||
"@getpaseo/plugin": "0.10.0",
|
"@getpaseo/plugin": "0.9.2",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.9.2",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.9.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39243,7 +39238,6 @@
|
|||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "2.9.1",
|
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40102,21 +40096,6 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"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": {
|
"packages/server/node_modules/yocto-queue": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
@@ -40131,7 +40110,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
@@ -41733,18 +41712,6 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"name": "@getpaseo/builtin-plugins",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@getpaseo/plugin": "*",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.9.0",
|
|
||||||
"vitest": "^4.1.6"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
"url": "https://github.com/getpaseo/paseo.git"
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"plugins",
|
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
"packages/highlight",
|
"packages/highlight",
|
||||||
"packages/plugin",
|
"packages/plugin",
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"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/builtin-plugins && 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/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"format": "oxfmt .",
|
"format": "oxfmt .",
|
||||||
"format:files": "oxfmt",
|
"format:files": "oxfmt",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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,14 +17,12 @@ import {
|
|||||||
hoverChatOutlinePrompt,
|
hoverChatOutlinePrompt,
|
||||||
chatOutlineRail,
|
chatOutlineRail,
|
||||||
movePointerOffChatOutline,
|
movePointerOffChatOutline,
|
||||||
observePromptIndexRequests,
|
|
||||||
pointAtChatOutlineRowEdge,
|
pointAtChatOutlineRowEdge,
|
||||||
pressEnterOnFocusedPrompt,
|
pressEnterOnFocusedPrompt,
|
||||||
splitCurrentPanelRight,
|
splitCurrentPanelRight,
|
||||||
withStreamingMarkdownOutline,
|
withStreamingMarkdownOutline,
|
||||||
expectReadingStreamedMarkdown,
|
expectReadingStreamedMarkdown,
|
||||||
} from "../support/helpers/chat-outline";
|
} from "../support/helpers/chat-outline";
|
||||||
import { createCreationScenario } from "../support/helpers/creation";
|
|
||||||
import {
|
import {
|
||||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||||
expectTimelinePromptNotMounted,
|
expectTimelinePromptNotMounted,
|
||||||
@@ -268,21 +266,3 @@ 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,25 +87,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
|
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||||
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" });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||||
@@ -114,8 +96,8 @@ async function createFeatureCommit(
|
|||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_AUTHOR_DATE: authorDate,
|
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||||
GIT_COMMITTER_DATE: authorDate,
|
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
|||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
|
||||||
|
|
||||||
const TEST_COMMANDS = [
|
const TEST_COMMANDS = [
|
||||||
{
|
{
|
||||||
@@ -339,16 +338,6 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
|||||||
).toBeUndefined();
|
).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 {
|
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||||
const firstVisibleIndex = frames.findIndex(
|
const firstVisibleIndex = frames.findIndex(
|
||||||
(frame) =>
|
(frame) =>
|
||||||
@@ -370,25 +359,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Composer autocomplete", () => {
|
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 }) => {
|
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||||
await installListCommandsStub(page);
|
await installListCommandsStub(page);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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,11 +7,7 @@ import {
|
|||||||
expectFileTabOpen,
|
expectFileTabOpen,
|
||||||
} from "../support/helpers/file-explorer";
|
} from "../support/helpers/file-explorer";
|
||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
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";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
@@ -96,40 +92,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
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 ({
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { TestInfo } from "@playwright/test";
|
import type { TestInfo } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { test, type Page } from "../support/fixtures";
|
||||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||||
import {
|
import {
|
||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
@@ -49,7 +49,6 @@ interface ImportFlowScenario {
|
|||||||
worktreeDirectory: string;
|
worktreeDirectory: string;
|
||||||
unrelatedDirectory: string;
|
unrelatedDirectory: string;
|
||||||
importSessionId: string;
|
importSessionId: string;
|
||||||
importSessionTimestamp: number;
|
|
||||||
repoCleanup(): Promise<void>;
|
repoCleanup(): Promise<void>;
|
||||||
unrelatedCleanup(): Promise<void>;
|
unrelatedCleanup(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,7 @@ test.beforeAll(async () => {
|
|||||||
};
|
};
|
||||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||||
const importSessionId = "fixture-custom-title";
|
const importSessionId = "fixture-custom-title";
|
||||||
const importSessionTimestamp = await seedClaudeSessions({
|
await seedClaudeSessions({
|
||||||
projectRoot: repo.path,
|
projectRoot: repo.path,
|
||||||
worktreeDirectory,
|
worktreeDirectory,
|
||||||
unrelatedDirectory: unrelated.path,
|
unrelatedDirectory: unrelated.path,
|
||||||
@@ -109,7 +108,6 @@ test.beforeAll(async () => {
|
|||||||
worktreeDirectory,
|
worktreeDirectory,
|
||||||
unrelatedDirectory: unrelated.path,
|
unrelatedDirectory: unrelated.path,
|
||||||
importSessionId,
|
importSessionId,
|
||||||
importSessionTimestamp,
|
|
||||||
repoCleanup: repo.cleanup,
|
repoCleanup: repo.cleanup,
|
||||||
unrelatedCleanup: unrelated.cleanup,
|
unrelatedCleanup: unrelated.cleanup,
|
||||||
};
|
};
|
||||||
@@ -124,19 +122,6 @@ test.afterAll(async () => {
|
|||||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
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) => {
|
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
||||||
const flow = new ImportSessionFlow(page);
|
const flow = new ImportSessionFlow(page);
|
||||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||||
@@ -240,7 +225,7 @@ async function seedClaudeSessions(input: {
|
|||||||
worktreeDirectory: string;
|
worktreeDirectory: string;
|
||||||
unrelatedDirectory: string;
|
unrelatedDirectory: string;
|
||||||
importSessionId: string;
|
importSessionId: string;
|
||||||
}): Promise<number> {
|
}): Promise<void> {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{
|
{
|
||||||
cwd: input.projectRoot,
|
cwd: input.projectRoot,
|
||||||
@@ -305,5 +290,4 @@ async function seedClaudeSessions(input: {
|
|||||||
const timestamp = new Date(newest - index * 60_000);
|
const timestamp = new Date(newest - index * 60_000);
|
||||||
await utimes(sessionPath, timestamp, timestamp);
|
await utimes(sessionPath, timestamp, timestamp);
|
||||||
}
|
}
|
||||||
return newest;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,52 +250,6 @@ test.describe("Tab creation", () => {
|
|||||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
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 }) => {
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -119,25 +119,6 @@ async function exerciseProviderSettingsStack(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("provider settings overlay stack", () => {
|
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 ({
|
test("provider settings covers the desktop model selector without closing it", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import { expect, test } from "../support/fixtures";
|
|||||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||||
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import {
|
import {
|
||||||
expectSettingsHeader,
|
expectSettingsHeader,
|
||||||
@@ -19,12 +18,6 @@ import {
|
|||||||
expectRetiredSidebarSectionsAbsent,
|
expectRetiredSidebarSectionsAbsent,
|
||||||
expectHostPageVisible,
|
expectHostPageVisible,
|
||||||
seedSavedSettingsHosts,
|
seedSavedSettingsHosts,
|
||||||
selectSettingsHost,
|
|
||||||
expectHostRejectedWithReAddGuidance,
|
|
||||||
expectNoHostPasswordControls,
|
|
||||||
removeHostFromHostPage,
|
|
||||||
addDirectHostFromSettings,
|
|
||||||
expectHostOnlineWithoutError,
|
|
||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
@@ -96,47 +89,6 @@ test.describe("Settings host page", () => {
|
|||||||
await expect(updateButton).toBeEnabled();
|
await expect(updateButton).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a host rejected for its password reconnects after being removed and added again with it", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const password = "e2e host password";
|
|
||||||
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
|
|
||||||
environment: { ...process.env, PASEO_PASSWORD: password },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await seedSavedSettingsHosts(page, [
|
|
||||||
{
|
|
||||||
serverId: getServerId(),
|
|
||||||
label: TEST_HOST_LABEL,
|
|
||||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: daemon.serverId,
|
|
||||||
label: "Password host",
|
|
||||||
endpoint: `127.0.0.1:${daemon.port}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await page.reload();
|
|
||||||
await openSettings(page);
|
|
||||||
await selectSettingsHost(page, daemon.serverId);
|
|
||||||
await openHostSection(page, daemon.serverId, "host");
|
|
||||||
|
|
||||||
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
|
|
||||||
await expectHostRejectedWithReAddGuidance(page, "Password required");
|
|
||||||
await expectNoHostPasswordControls(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("removing the host and adding it again with the password brings it online", async () => {
|
|
||||||
await removeHostFromHostPage(page, daemon.serverId);
|
|
||||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
|
|
||||||
await openHostSection(page, daemon.serverId, "host");
|
|
||||||
await expectHostOnlineWithoutError(page);
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
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 } 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("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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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,7 +3,6 @@ import { openSettings } from "./app";
|
|||||||
import { openSettingsSection } from "./settings";
|
import { openSettingsSection } from "./settings";
|
||||||
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
||||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||||
import { loadSessionMessageReaders } from "./new-workspace";
|
|
||||||
|
|
||||||
export async function withStreamingMarkdownOutline(
|
export async function withStreamingMarkdownOutline(
|
||||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||||
@@ -84,7 +83,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
|||||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||||
const timelineUrl = page.url();
|
const timelineUrl = page.url();
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsSection(page, "chat");
|
await openSettingsSection(page, "appearance");
|
||||||
await page.getByRole("switch", { name: "Chat outline" }).click();
|
await page.getByRole("switch", { name: "Chat outline" }).click();
|
||||||
await page.goto(timelineUrl);
|
await page.goto(timelineUrl);
|
||||||
}
|
}
|
||||||
@@ -230,22 +229,3 @@ async function requireBoundingBox(
|
|||||||
}
|
}
|
||||||
return box;
|
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,12 +206,6 @@ export async function createCreationScenario(page: Page) {
|
|||||||
)
|
)
|
||||||
.toBe(count);
|
.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) {
|
async expectAgentTitle(title: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|||||||
@@ -41,13 +41,6 @@ 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> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const deadline = Date.now() + 90_000;
|
const deadline = Date.now() + 90_000;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -88,7 +81,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
options: IsolatedHostDaemonOptions = {},
|
options: IsolatedHostDaemonOptions = {},
|
||||||
): Promise<IsolatedHostDaemon> {
|
): Promise<IsolatedHostDaemon> {
|
||||||
const port = await getAvailableHostDaemonPort();
|
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||||
|
let port = await getAvailablePort();
|
||||||
|
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||||
|
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async function openPluginScreen(page: Page) {
|
|||||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||||
}
|
}
|
||||||
export async function groupByWorkspace(page: Page) {
|
export async function groupByWorkspace(page: Page) {
|
||||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,12 +20,8 @@ interface SavedSettingsHostInput {
|
|||||||
|
|
||||||
const SECTION_LABELS = {
|
const SECTION_LABELS = {
|
||||||
general: "General",
|
general: "General",
|
||||||
chat: "Chat",
|
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
sidebar: "Sidebar",
|
|
||||||
editor: "Editor",
|
editor: "Editor",
|
||||||
terminal: "Terminal",
|
|
||||||
browser: "Browser",
|
|
||||||
shortcuts: "Shortcuts",
|
shortcuts: "Shortcuts",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
@@ -74,7 +70,7 @@ export async function openSettingsHostSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||||
await expect(page.getByTestId("page-title")).toHaveText(title);
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||||
@@ -95,15 +91,12 @@ export async function selectHostConnectionType(
|
|||||||
|
|
||||||
export async function addDirectHostFromSettings(
|
export async function addDirectHostFromSettings(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { host: string; port: number; password?: string },
|
input: { host: string; port: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await openAddHostFlow(page);
|
await openAddHostFlow(page);
|
||||||
await selectHostConnectionType(page, "direct");
|
await selectHostConnectionType(page, "direct");
|
||||||
await page.getByTestId("direct-host-input").fill(input.host);
|
await page.getByTestId("direct-host-input").fill(input.host);
|
||||||
await page.getByTestId("direct-port-input").fill(String(input.port));
|
await page.getByTestId("direct-port-input").fill(String(input.port));
|
||||||
if (input.password) {
|
|
||||||
await page.getByTestId("direct-password-input").fill(input.password);
|
|
||||||
}
|
|
||||||
await page.getByTestId("direct-host-submit").click();
|
await page.getByTestId("direct-host-submit").click();
|
||||||
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
@@ -425,31 +418,3 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
|||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectHostRejectedWithReAddGuidance(
|
|
||||||
page: Page,
|
|
||||||
reason: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const error = page.getByTestId("host-connection-error");
|
|
||||||
await expect(error).toContainText(reason);
|
|
||||||
await expect(error).toContainText(
|
|
||||||
"Remove this host and add it again with the password this daemon asks for.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
|
|
||||||
await page.getByTestId("host-page-remove-host-button").click();
|
|
||||||
await page.getByTestId("remove-host-confirm").click();
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
|
||||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
|
|||||||
|
|
||||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsSection(page, "sidebar");
|
await openSettingsSection(page, "appearance");
|
||||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.10.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe("chat outline prompt index epoch", () => {
|
|||||||
it("accepts only the authoritative timeline epoch", () => {
|
it("accepts only the authoritative timeline epoch", () => {
|
||||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
||||||
|
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
|
|||||||
|
|
||||||
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||||
|
|
||||||
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
|
export function shouldAcceptPromptIndexEpoch(
|
||||||
return timelineEpoch === indexEpoch;
|
timelineEpoch: string | null,
|
||||||
|
indexEpoch: string,
|
||||||
|
): boolean {
|
||||||
|
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,31 +37,6 @@ describe("useChatOutline", () => {
|
|||||||
runtime.subscribeAgentTimeline.mockClear();
|
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 () => {
|
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||||
const second = deferred<{
|
const second = deferred<{
|
||||||
|
|||||||
@@ -77,10 +77,8 @@ export function useChatOutline({
|
|||||||
|
|
||||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !enabled || timelineEpoch === null) {
|
if (!isWeb || !enabled) {
|
||||||
setIndex(null);
|
setIndex(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,17 +210,6 @@ describe("stream presentation through installed plugins", () => {
|
|||||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
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,
|
// 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.
|
// 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
|
// A link reference definition stays with the paragraph that uses it, so the split
|
||||||
|
|||||||
@@ -112,10 +112,8 @@ export function createStreamPresentation() {
|
|||||||
const index = prefix.length + offset;
|
const index = prefix.length + offset;
|
||||||
let blockText = text;
|
let blockText = text;
|
||||||
if (offset === textBlocks.length - 1) {
|
if (offset === textBlocks.length - 1) {
|
||||||
// The split drops trailing blank lines, including a line break followed only
|
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
||||||
// by the next line's indent. Keep them so the next append continues that line.
|
blockText += trailingNewlines;
|
||||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
|
||||||
blockText += trailingBlankLines;
|
|
||||||
}
|
}
|
||||||
const existing = previous?.[index];
|
const existing = previous?.[index];
|
||||||
const id = `${item.id}:block:${index}`;
|
const id = `${item.id}:block:${index}`;
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import {
|
|||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
hasConfiguredLocalDaemonOverride,
|
||||||
useHostRegistryLoaded,
|
useHostRegistryLoaded,
|
||||||
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
useHosts,
|
useHosts,
|
||||||
@@ -665,11 +666,13 @@ function MobileGestureWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppearanceProvider>
|
<AppearanceProvider>
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<DesktopWindowControlsSync />
|
<DesktopWindowControlsSync />
|
||||||
<OfferLinkListener />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
<HostSessionManager />
|
<HostSessionManager />
|
||||||
<FaviconStatusSync />
|
<FaviconStatusSync />
|
||||||
{children}
|
{children}
|
||||||
@@ -696,36 +699,45 @@ function DesktopWindowControlsSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OfferLinkListener() {
|
function OfferLinkListener({
|
||||||
|
upsertDaemonFromOfferUrl,
|
||||||
|
}: {
|
||||||
|
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const handleUrl = async (url: string | null) => {
|
const handleUrl = (url: string | null) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
|
if (!url.includes("#offer=")) return;
|
||||||
return;
|
void upsertDaemonFromOfferUrl(url)
|
||||||
try {
|
.then((profile) => {
|
||||||
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
|
if (cancelled) return;
|
||||||
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
|
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
||||||
} catch (error) {
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
console.warn("[OfferLinkListener] Pairing link failed", error);
|
router.replace(buildOpenProjectRoute());
|
||||||
}
|
return;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn("[Linking] Failed to import pairing offer", error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void Linking.getInitialURL()
|
void Linking.getInitialURL()
|
||||||
.then((url) => handleUrl(url))
|
.then(handleUrl)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
const subscription = Linking.addEventListener("url", (event) => {
|
const subscription = Linking.addEventListener("url", (event) => {
|
||||||
void handleUrl(event.url);
|
handleUrl(event.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [router]);
|
}, [router, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -860,7 +872,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
pathname === "/new" ||
|
pathname === "/new" ||
|
||||||
pathname === "/sessions" ||
|
pathname === "/sessions" ||
|
||||||
pathname === "/schedules" ||
|
pathname === "/schedules" ||
|
||||||
pathname === "/usage" ||
|
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||||
@@ -893,7 +904,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="schedules" />
|
<Stack.Screen name="schedules" />
|
||||||
<Stack.Screen name="usage" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { useHostMutations } from "@/runtime/host-runtime";
|
||||||
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
|
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -112,8 +114,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
|||||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
if (raw.includes("#offer=")) return raw;
|
||||||
return raw;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -127,11 +128,11 @@ export default function PairScanScreen() {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const navigateToPairedHost = useCallback(
|
const navigateToPairedHost = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -159,34 +160,48 @@ export default function PairScanScreen() {
|
|||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
(result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
|
|
||||||
const store = getHostRuntimeStore();
|
if (lastScannedRef.current === offerUrl) return;
|
||||||
if (passwordOfferUrl) return;
|
lastScannedRef.current = offerUrl;
|
||||||
setIsPairing(true);
|
|
||||||
setScanError(null);
|
try {
|
||||||
void store
|
setIsPairing(true);
|
||||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
const idx = offerUrl.indexOf("#offer=");
|
||||||
.then((outcome) => {
|
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
|
||||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||||
else setPasswordOfferUrl(offerUrl);
|
const offer = ConnectionOfferSchema.parse(offerPayload);
|
||||||
return outcome;
|
|
||||||
})
|
const { client, hostname } = await connectToDaemon(
|
||||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
{
|
||||||
.finally(() => setIsPairing(false));
|
id: "probe",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
||||||
|
useTls: offer.relay.useTls,
|
||||||
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
|
},
|
||||||
|
{ serverId: offer.serverId },
|
||||||
|
);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
|
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||||
|
|
||||||
|
navigateToPairedHost(profile.serverId);
|
||||||
|
} catch (error) {
|
||||||
|
lastScannedRef.current = null;
|
||||||
|
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||||
|
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||||
|
} finally {
|
||||||
|
setIsPairing(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||||
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
|
||||||
const savePasswordPairing = useCallback(
|
|
||||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
|
||||||
[navigateToPairedHost],
|
|
||||||
);
|
|
||||||
const handleRequestPermission = useCallback(() => {
|
const handleRequestPermission = useCallback(() => {
|
||||||
void requestPermission();
|
void requestPermission();
|
||||||
}, [requestPermission]);
|
}, [requestPermission]);
|
||||||
@@ -248,18 +263,10 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||||
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<PairLinkModal
|
|
||||||
visible={passwordOfferUrl !== null}
|
|
||||||
initialUrl={passwordOfferUrl ?? undefined}
|
|
||||||
initialPasswordRequired
|
|
||||||
onClose={closePasswordModal}
|
|
||||||
onSaved={savePasswordPairing}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { UsageScreen } from "@/usage";
|
|
||||||
|
|
||||||
export default function UsageRoute() {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<UsageScreen />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|||||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||||
import type { AssistantFileLinkSource } from "./resolver";
|
import type { AssistantFileLinkSource } from "./resolver";
|
||||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
|
||||||
import { useFileLink } from "./use-file-link";
|
import { useFileLink } from "./use-file-link";
|
||||||
|
|
||||||
interface AssistantMarkdownLinkProps {
|
interface AssistantMarkdownLinkProps {
|
||||||
@@ -35,7 +34,7 @@ export function AssistantMarkdownLink({
|
|||||||
const { configRef } = useAssistantFileLinkResolverContext();
|
const { configRef } = useAssistantFileLinkResolverContext();
|
||||||
const workspaceRoot = configRef.current.workspaceRoot;
|
const workspaceRoot = configRef.current.workspaceRoot;
|
||||||
const tooltipPath = useMemo(
|
const tooltipPath = useMemo(
|
||||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||||
[target, workspaceRoot],
|
[target, workspaceRoot],
|
||||||
);
|
);
|
||||||
const linkPress = useMemo<AssistantLinkPress>(
|
const linkPress = useMemo<AssistantLinkPress>(
|
||||||
@@ -128,6 +127,38 @@ 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 {
|
interface AssistantInlineCodePathLinkProps {
|
||||||
content: string;
|
content: string;
|
||||||
inheritedStyles: TextStyle;
|
inheritedStyles: TextStyle;
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -112,7 +112,6 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
|
|||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<Alert
|
<Alert
|
||||||
size="md"
|
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("changelog.error.title")}
|
title={t("changelog.error.title")}
|
||||||
description={t("changelog.error.description")}
|
description={t("changelog.error.description")}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -11,13 +11,9 @@ import {
|
|||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
serializeConnectionUriForStorage,
|
serializeConnectionUriForStorage,
|
||||||
} from "@/utils/daemon-endpoints";
|
} from "@/utils/daemon-endpoints";
|
||||||
import {
|
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||||
DaemonConnectionTestError,
|
|
||||||
getConnectionAuthFailureReason,
|
|
||||||
} from "@/utils/test-daemon-connection";
|
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
@@ -254,8 +250,8 @@ function buildConnectionFailureCopy(input: {
|
|||||||
const rawLower = raw?.toLowerCase() ?? "";
|
const rawLower = raw?.toLowerCase() ?? "";
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
||||||
if (getConnectionAuthFailureReason(error)) {
|
if (raw === "Incorrect password" || raw === "Password required") {
|
||||||
detail = error instanceof Error ? error.message : raw;
|
detail = raw;
|
||||||
} else if (rawLower.includes("timed out")) {
|
} else if (rawLower.includes("timed out")) {
|
||||||
detail = labels.timedOut;
|
detail = labels.timedOut;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -297,8 +293,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||||
useHostMutations();
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -311,7 +306,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||||
const [advancedUri, setAdvancedUri] = useState("");
|
const [advancedUri, setAdvancedUri] = useState("");
|
||||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
const clearInput = useCallback(() => {
|
||||||
setHost("");
|
setHost("");
|
||||||
@@ -321,7 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
setIsPasswordVisible(false);
|
setIsPasswordVisible(false);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(false);
|
||||||
setAdvancedUri("");
|
setAdvancedUri("");
|
||||||
advancedTarget.current = new PairingTargetTracker("", true);
|
|
||||||
bumpInputResetKey();
|
bumpInputResetKey();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -372,45 +365,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, clearInput, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSaveRelay = useCallback(
|
|
||||||
async (relayUri: string) => {
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
setErrorMessage("");
|
|
||||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
|
||||||
relayUri,
|
|
||||||
password || undefined,
|
|
||||||
);
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
|
||||||
handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(
|
|
||||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
daemons,
|
|
||||||
directConnectionLabels.invalidConnection,
|
|
||||||
handleClose,
|
|
||||||
onSaved,
|
|
||||||
password,
|
|
||||||
probeAndUpsertConnectionFromOfferUrl,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
|
|
||||||
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
|
|
||||||
await handleSaveRelay(relayUri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let connection: PreparedDirectConnection;
|
let connection: PreparedDirectConnection;
|
||||||
try {
|
try {
|
||||||
connection = prepareDirectConnection(
|
connection = prepareDirectConnection(
|
||||||
@@ -465,12 +422,10 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
advancedUri,
|
|
||||||
daemons,
|
daemons,
|
||||||
directConnectionLabels,
|
directConnectionLabels,
|
||||||
handleClose,
|
handleClose,
|
||||||
host,
|
host,
|
||||||
isAdvancedOpen,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
isSaving,
|
isSaving,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -478,7 +433,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
port,
|
port,
|
||||||
probeAndUpsertDirectConnection,
|
probeAndUpsertDirectConnection,
|
||||||
t,
|
t,
|
||||||
handleSaveRelay,
|
|
||||||
useTls,
|
useTls,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -490,15 +444,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
void handleSave();
|
void handleSave();
|
||||||
}, [handleSave]);
|
}, [handleSave]);
|
||||||
|
|
||||||
const handleChangeAdvancedUri = useCallback((next: string) => {
|
|
||||||
if (advancedTarget.current.changeUrl(next)) {
|
|
||||||
setPassword("");
|
|
||||||
bumpInputResetKey();
|
|
||||||
setErrorMessage("");
|
|
||||||
}
|
|
||||||
setAdvancedUri(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleUseTls = useCallback(() => {
|
const handleToggleUseTls = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setUseTls((current) => !current);
|
setUseTls((current) => !current);
|
||||||
@@ -510,7 +455,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
|
|
||||||
const handleToggleAdvanced = useCallback(() => {
|
const handleToggleAdvanced = useCallback(() => {
|
||||||
if (!isAdvancedOpen) {
|
if (!isAdvancedOpen) {
|
||||||
advancedTarget.current = new PairingTargetTracker("", true);
|
|
||||||
try {
|
try {
|
||||||
setAdvancedUri(
|
setAdvancedUri(
|
||||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||||
@@ -668,7 +612,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||||
initialValue={advancedUri}
|
initialValue={advancedUri}
|
||||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||||
onChangeText={handleChangeAdvancedUri}
|
onChangeText={setAdvancedUri}
|
||||||
placeholder="tcp://localhost:6767?ssl=true"
|
placeholder="tcp://localhost:6767?ssl=true"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive, ChevronRight } from "lucide-react-native";
|
import { Archive, ChevronRight } from "lucide-react-native";
|
||||||
@@ -181,6 +181,7 @@ function SessionRow({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||||
@@ -282,7 +283,7 @@ function SessionRow({
|
|||||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||||
<AgentActivityTime date={agent.lastActivityAt} isMobile />
|
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||||
{showHostColumn && agent.serverLabel ? (
|
{showHostColumn && agent.serverLabel ? (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||||
@@ -315,7 +316,9 @@ function SessionRow({
|
|||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||||
/>
|
/>
|
||||||
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
|
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||||
|
{timeAgo}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<SessionRowTrailingAttention
|
<SessionRowTrailingAttention
|
||||||
@@ -327,15 +330,6 @@ 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({
|
export function AgentList({
|
||||||
agents,
|
agents,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import Svg, { Circle } from "react-native-svg";
|
import Svg, { Circle } from "react-native-svg";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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";
|
import { formatTokenCount } from "./context-window-meter.utils";
|
||||||
|
|
||||||
interface ContextWindowMeterProps {
|
interface ContextWindowMeterProps {
|
||||||
@@ -10,6 +13,9 @@ interface ContextWindowMeterProps {
|
|||||||
usedTokens: number | null;
|
usedTokens: number | null;
|
||||||
totalCostUsd?: number | null;
|
totalCostUsd?: number | null;
|
||||||
showPercentage?: boolean;
|
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. */
|
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||||
@@ -95,13 +101,30 @@ export function ContextWindowMeter({
|
|||||||
usedTokens,
|
usedTokens,
|
||||||
totalCostUsd,
|
totalCostUsd,
|
||||||
showPercentage = false,
|
showPercentage = false,
|
||||||
|
serverId,
|
||||||
|
provider,
|
||||||
pending = false,
|
pending = false,
|
||||||
glyphSize,
|
glyphSize,
|
||||||
}: ContextWindowMeterProps) {
|
}: ContextWindowMeterProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||||
|
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
|
||||||
|
serverId ?? null,
|
||||||
|
{ enabled: isTooltipOpen },
|
||||||
|
);
|
||||||
const percentage =
|
const percentage =
|
||||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
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);
|
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||||
|
|
||||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||||
@@ -117,6 +140,7 @@ export function ContextWindowMeter({
|
|||||||
width={geometry.svgSize}
|
width={geometry.svgSize}
|
||||||
height={geometry.svgSize}
|
height={geometry.svgSize}
|
||||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||||
|
style={styles.svg}
|
||||||
accessibilityElementsHidden
|
accessibilityElementsHidden
|
||||||
importantForAccessibility="no-hide-descendants"
|
importantForAccessibility="no-hide-descendants"
|
||||||
>
|
>
|
||||||
@@ -143,7 +167,13 @@ export function ContextWindowMeter({
|
|||||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
<Tooltip
|
||||||
|
open={isTooltipOpen}
|
||||||
|
onOpenChange={handleTooltipOpenChange}
|
||||||
|
delayDuration={0}
|
||||||
|
enabledOnDesktop
|
||||||
|
enabledOnMobile
|
||||||
|
>
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<Pressable
|
<Pressable
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
@@ -157,6 +187,7 @@ export function ContextWindowMeter({
|
|||||||
width={svgSize}
|
width={svgSize}
|
||||||
height={svgSize}
|
height={svgSize}
|
||||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||||
|
style={styles.svg}
|
||||||
accessibilityElementsHidden
|
accessibilityElementsHidden
|
||||||
importantForAccessibility="no-hide-descendants"
|
importantForAccessibility="no-hide-descendants"
|
||||||
>
|
>
|
||||||
@@ -178,8 +209,6 @@ export function ContextWindowMeter({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
strokeDashoffset={dashOffset}
|
strokeDashoffset={dashOffset}
|
||||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
|
||||||
transform={`rotate(-90 ${center} ${center})`}
|
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
{showPercentage ? (
|
{showPercentage ? (
|
||||||
@@ -204,6 +233,7 @@ export function ContextWindowMeter({
|
|||||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -226,6 +256,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
|
svg: {
|
||||||
|
transform: [{ rotate: "-90deg" }],
|
||||||
|
},
|
||||||
percentageLabel: {
|
percentageLabel: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostProjects } from "@/projects/host-projects";
|
import { useHostProjects } from "@/projects/host-projects";
|
||||||
@@ -309,6 +309,7 @@ function ImportSessionSheetRow({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const title = getSessionTitle(entry);
|
const title = getSessionTitle(entry);
|
||||||
const promptPreview = getPromptPreview(entry);
|
const promptPreview = getPromptPreview(entry);
|
||||||
|
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||||
@@ -343,11 +344,9 @@ function ImportSessionSheetRow({
|
|||||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{importing ? (
|
<Text style={styles.rowMeta}>
|
||||||
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
|
{importing ? t("importSession.row.importing") : lastActivity}
|
||||||
) : (
|
</Text>
|
||||||
<ImportSessionActivityTime date={entry.lastActivityAt} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||||
{promptPreview}
|
{promptPreview}
|
||||||
@@ -366,11 +365,6 @@ function ImportSessionSheetRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportSessionActivityTime({ date }: { date: string }) {
|
|
||||||
const label = useTimeAgo(new Date(date));
|
|
||||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionRows({
|
function SessionRows({
|
||||||
serverId,
|
serverId,
|
||||||
entries,
|
entries,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -47,11 +47,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import {
|
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||||
buildSettingsAddHostRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
buildUsageRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
@@ -430,25 +426,6 @@ 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({
|
function IconTooltipContent({
|
||||||
label,
|
label,
|
||||||
shortcutKeys,
|
shortcutKeys,
|
||||||
@@ -512,7 +489,6 @@ function SidebarFooter({
|
|||||||
icon={Import}
|
icon={Import}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
<SidebarUsageButton theme={theme} />
|
|
||||||
<SidebarHelpMenu />
|
<SidebarHelpMenu />
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleSettings}
|
onPress={handleSettings}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
|
||||||
|
|
||||||
describe("pairing target password", () => {
|
|
||||||
it("clears the password and hides its input when switching hosts", () => {
|
|
||||||
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
|
||||||
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
|
|
||||||
const target = new PairingTargetTracker(first);
|
|
||||||
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
|
|
||||||
expect(target.changeUrl(second)).toBe(true);
|
|
||||||
expect(target.changeUrl(second)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
|
|
||||||
const target = new PairingTargetTracker("", true);
|
|
||||||
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the password when a relay URI receives harmless whitespace", () => {
|
|
||||||
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
|
||||||
const target = new PairingTargetTracker(uri);
|
|
||||||
expect(target.changeUrl(`${uri} `)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recognizes a different host in an offer link after an incomplete edit", () => {
|
|
||||||
const offer = Buffer.from(
|
|
||||||
JSON.stringify({
|
|
||||||
v: 2,
|
|
||||||
serverId: "srv_b",
|
|
||||||
daemonPublicKeyB64: "BBBB",
|
|
||||||
relay: { endpoint: "relay.example:443" },
|
|
||||||
}),
|
|
||||||
).toString("base64url");
|
|
||||||
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
|
|
||||||
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
|
|
||||||
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
|
||||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
|
||||||
|
|
||||||
function pairingTarget(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const trimmed = url.trim();
|
|
||||||
const offer =
|
|
||||||
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
|
|
||||||
? parseRelayConnectionUri(trimmed).offer
|
|
||||||
: parseConnectionOfferFromUrl(trimmed);
|
|
||||||
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
|
|
||||||
} catch {
|
|
||||||
// Keep the last valid target while the user edits an incomplete URI.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tracks the last valid host so harmless edits never clear its password. */
|
|
||||||
export class PairingTargetTracker {
|
|
||||||
private currentTarget: string | null;
|
|
||||||
|
|
||||||
constructor(initialUrl = "", startsFromDirectForm = false) {
|
|
||||||
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
changeUrl(nextUrl: string): boolean {
|
|
||||||
const nextTarget = pairingTarget(nextUrl);
|
|
||||||
if (!nextTarget) return false;
|
|
||||||
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
|
|
||||||
this.currentTarget = nextTarget;
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -6,27 +6,15 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
|
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
function parsedHostLabel(input: string): string {
|
|
||||||
try {
|
|
||||||
if (input.startsWith("relay://") || input.includes("#connect=")) {
|
|
||||||
return parseRelayConnectionUri(input).offer.serverId;
|
|
||||||
}
|
|
||||||
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
|
|
||||||
} catch {
|
|
||||||
return "host";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
helper: {
|
helper: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -62,8 +50,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export interface PairLinkModalProps {
|
export interface PairLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
initialUrl?: string;
|
|
||||||
initialPasswordRequired?: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
@@ -74,57 +60,21 @@ export interface PairLinkModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairLinkModal({
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||||
visible,
|
|
||||||
initialUrl,
|
|
||||||
initialPasswordRequired = false,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
}: PairLinkModalProps) {
|
|
||||||
return (
|
|
||||||
<PairLinkModalContent
|
|
||||||
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
|
||||||
visible={visible}
|
|
||||||
initialUrl={initialUrl}
|
|
||||||
initialPasswordRequired={initialPasswordRequired}
|
|
||||||
onClose={onClose}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSaved={onSaved}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PairLinkModalContent({
|
|
||||||
visible,
|
|
||||||
initialUrl,
|
|
||||||
initialPasswordRequired = false,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
}: PairLinkModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const offerUrlRef = useRef(initialUrl ?? "");
|
const offerUrlRef = useRef("");
|
||||||
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
|
|
||||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
|
||||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
const clearInput = useCallback(() => {
|
||||||
offerUrlRef.current = "";
|
offerUrlRef.current = "";
|
||||||
targetTracker.current = new PairingTargetTracker();
|
|
||||||
inputRef.current?.replaceText("");
|
inputRef.current?.replaceText("");
|
||||||
setPassword("");
|
|
||||||
setNeedsPassword(false);
|
|
||||||
resetPasswordInput();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pairIcon = useMemo(
|
const pairIcon = useMemo(
|
||||||
@@ -146,63 +96,74 @@ function PairLinkModalContent({
|
|||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, clearInput, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSave = useCallback(
|
const handleSave = useCallback(async () => {
|
||||||
async (input?: string) => {
|
if (isSaving) return;
|
||||||
if (isSaving) return;
|
const raw = offerUrlRef.current.trim();
|
||||||
const raw = (input ?? offerUrlRef.current).trim();
|
if (!raw) {
|
||||||
if (!raw) {
|
setErrorMessage(t("pairing.link.errors.required"));
|
||||||
setErrorMessage(t("pairing.link.errors.required"));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (!raw.includes("#offer=")) {
|
||||||
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
|
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const parsedOffer = (() => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
const idx = raw.indexOf("#offer=");
|
||||||
setErrorMessage("");
|
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
if (!encoded) {
|
||||||
raw,
|
throw new Error(t("pairing.link.errors.emptyOffer"));
|
||||||
password || undefined,
|
|
||||||
);
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
|
||||||
handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
|
||||||
setErrorMessage(message);
|
|
||||||
if (getConnectionAuthFailureReason(error)) {
|
|
||||||
setNeedsPassword(true);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const payload = decodeOfferFragmentPayload(encoded);
|
||||||
|
return ConnectionOfferSchema.parse(payload);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
||||||
|
setErrorMessage(message);
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||||
}
|
}
|
||||||
} finally {
|
return null;
|
||||||
setIsSaving(false);
|
|
||||||
}
|
}
|
||||||
},
|
})();
|
||||||
[
|
|
||||||
daemons,
|
if (!parsedOffer) {
|
||||||
handleClose,
|
return;
|
||||||
isMobile,
|
}
|
||||||
isSaving,
|
|
||||||
onSaved,
|
try {
|
||||||
password,
|
setIsSaving(true);
|
||||||
t,
|
setErrorMessage("");
|
||||||
probeAndUpsertConnectionFromOfferUrl,
|
|
||||||
],
|
const { client, hostname } = await connectToDaemon(
|
||||||
);
|
{
|
||||||
|
id: "probe",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
||||||
|
useTls: parsedOffer.relay.useTls,
|
||||||
|
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
||||||
|
},
|
||||||
|
{ serverId: parsedOffer.serverId },
|
||||||
|
);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||||
|
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||||
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||||
|
setErrorMessage(message);
|
||||||
|
if (!isMobile) {
|
||||||
|
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||||
if (targetTracker.current.changeUrl(next)) {
|
|
||||||
setPassword("");
|
|
||||||
setNeedsPassword(false);
|
|
||||||
resetPasswordInput();
|
|
||||||
setErrorMessage("");
|
|
||||||
}
|
|
||||||
offerUrlRef.current = next;
|
offerUrlRef.current = next;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -225,7 +186,6 @@ function PairLinkModalContent({
|
|||||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
initialValue={initialUrl}
|
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
nativeID="pair-link-input"
|
nativeID="pair-link-input"
|
||||||
accessibilityLabel={t("pairing.link.label")}
|
accessibilityLabel={t("pairing.link.label")}
|
||||||
@@ -241,22 +201,6 @@ function PairLinkModalContent({
|
|||||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{needsPassword ? (
|
|
||||||
<View style={styles.field}>
|
|
||||||
<Text style={styles.label}>
|
|
||||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
|
||||||
</Text>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
testID="pair-link-password-input"
|
|
||||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
|
||||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
secureTextEntry
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
style={FLEX_ONE_STYLE}
|
style={FLEX_ONE_STYLE}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
|
|||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||||
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderSheetFooterInput {
|
interface ProviderSheetFooterInput {
|
||||||
fetchedAt: string | null;
|
fetchedAtLabel: string | null;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
modelsRefreshing: boolean;
|
modelsRefreshing: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderProviderSheetFooter({
|
function renderProviderSheetFooter({
|
||||||
fetchedAt,
|
fetchedAtLabel,
|
||||||
isCompact,
|
isCompact,
|
||||||
modelsRefreshing,
|
modelsRefreshing,
|
||||||
t,
|
t,
|
||||||
@@ -430,10 +430,16 @@ function renderProviderSheetFooter({
|
|||||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||||
|
const metaStyle = isCompact
|
||||||
|
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||||
|
: sheetStyles.footerMeta;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={contentStyle}>
|
<View style={contentStyle}>
|
||||||
{fetchedAt || !isCompact ? (
|
{fetchedAtLabel || !isCompact ? (
|
||||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
<Text style={metaStyle} numberOfLines={1}>
|
||||||
|
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={actionsStyle}>
|
<View style={actionsStyle}>
|
||||||
<Button
|
<Button
|
||||||
@@ -471,25 +477,6 @@ 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) {
|
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
@@ -622,6 +609,18 @@ export function ProviderDiagnosticSheet({
|
|||||||
});
|
});
|
||||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@@ -687,7 +686,7 @@ export function ProviderDiagnosticSheet({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
testID="provider-settings-sheet"
|
testID="provider-settings-sheet"
|
||||||
footer={renderProviderSheetFooter({
|
footer={renderProviderSheetFooter({
|
||||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
fetchedAtLabel,
|
||||||
isCompact,
|
isCompact,
|
||||||
modelsRefreshing,
|
modelsRefreshing,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
|
|||||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||||
const snapshotIconComponents = new Map<string, { svg: string; component: 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 {
|
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||||
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||||
SvgIcon({ svg: iconSvg, size, color });
|
createElement(SvgXml, {
|
||||||
|
xml: iconSvg,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color,
|
||||||
|
});
|
||||||
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
||||||
return SvgProviderIcon;
|
return SvgProviderIcon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
resolveScheduleTitle,
|
resolveScheduleTitle,
|
||||||
scheduleProductName,
|
scheduleProductName,
|
||||||
} from "@/utils/schedule-format";
|
} from "@/utils/schedule-format";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||||
@@ -96,19 +96,16 @@ function stateBadge(state: ScheduleDerivedState): {
|
|||||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
// 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
|
// 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.
|
// next. Status lives on the badge, never repeated here.
|
||||||
function buildMeta(input: {
|
function buildMeta(
|
||||||
schedule: ScheduleSummary;
|
schedule: ScheduleSummary,
|
||||||
state: ScheduleDerivedState;
|
state: ScheduleDerivedState,
|
||||||
createdAgo: string;
|
serverName: string | undefined,
|
||||||
lastRunAgo: string;
|
singleHost: boolean,
|
||||||
serverName: string | undefined;
|
): string {
|
||||||
singleHost: boolean;
|
|
||||||
}): string {
|
|
||||||
const { schedule, state, serverName, singleHost } = input;
|
|
||||||
const parts = [
|
const parts = [
|
||||||
formatCadence(schedule.cadence),
|
formatCadence(schedule.cadence),
|
||||||
`Created ${input.createdAgo}`,
|
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||||
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
|
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||||
];
|
];
|
||||||
if (state === "active") {
|
if (state === "active") {
|
||||||
const next = formatNextRun(schedule.nextRunAt);
|
const next = formatNextRun(schedule.nextRunAt);
|
||||||
@@ -122,27 +119,6 @@ function buildMeta(input: {
|
|||||||
return parts.join(" · ");
|
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
|
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||||
function ProviderGlyph({
|
function ProviderGlyph({
|
||||||
@@ -192,6 +168,7 @@ export function ScheduleRow({
|
|||||||
const title = resolveScheduleTitle(schedule);
|
const title = resolveScheduleTitle(schedule);
|
||||||
const productName = scheduleProductName(schedule);
|
const productName = scheduleProductName(schedule);
|
||||||
const badge = stateBadge(state);
|
const badge = stateBadge(state);
|
||||||
|
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||||
|
|
||||||
const rowStyle = useCallback(
|
const rowStyle = useCallback(
|
||||||
@@ -229,12 +206,9 @@ export function ScheduleRow({
|
|||||||
<Text style={styles.target} numberOfLines={1}>
|
<Text style={styles.target} numberOfLines={1}>
|
||||||
{targetLabel}
|
{targetLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<ScheduleMeta
|
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||||
schedule={schedule}
|
{meta}
|
||||||
state={state}
|
</Text>
|
||||||
serverName={serverName}
|
|
||||||
singleHost={singleHost ?? false}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -117,16 +117,17 @@ export function SettingsSelect<Value extends string>({
|
|||||||
disabled,
|
disabled,
|
||||||
...row
|
...row
|
||||||
}: SettingsSelectProps<Value>) {
|
}: SettingsSelectProps<Value>) {
|
||||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
|
||||||
return (
|
return (
|
||||||
<SettingsRow {...row}>
|
<SettingsRow {...row}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownTrigger
|
<DropdownTrigger
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${row.label}: ${selectedLabel}`}
|
accessibilityLabel={row.label}
|
||||||
>
|
>
|
||||||
{selectedLabel}
|
<Text style={styles.value}>
|
||||||
|
{options.find((option) => option.value === value)?.label ?? value}
|
||||||
|
</Text>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
@@ -202,5 +203,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
||||||
control: { flexShrink: 1, maxWidth: "100%" },
|
control: { flexShrink: 1, maxWidth: "100%" },
|
||||||
|
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||||
input: { minWidth: 180 },
|
input: { minWidth: 180 },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
|
|||||||
/**
|
/**
|
||||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||||
* "compact": a row with no separator, for entries that
|
* "compact": a workspace-row-height row with no separator, for entries that
|
||||||
* sit in a header group whose wrapper owns the single divider.
|
* sit in a header group whose wrapper owns the single divider.
|
||||||
*/
|
*/
|
||||||
variant?: SidebarHeaderRowVariant;
|
variant?: SidebarHeaderRowVariant;
|
||||||
@@ -52,9 +52,10 @@ export function SidebarHeaderRow({
|
|||||||
const buttonStyle = useCallback(
|
const buttonStyle = useCallback(
|
||||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.button,
|
styles.button,
|
||||||
|
variant === "compact" && styles.buttonCompact,
|
||||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||||
],
|
],
|
||||||
[isActive],
|
[isActive, variant],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderChildren = useCallback(
|
const renderChildren = useCallback(
|
||||||
@@ -63,7 +64,7 @@ export function SidebarHeaderRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ThemedIcon
|
<ThemedIcon
|
||||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
size={ICON_SIZE.sm}
|
||||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||||
@@ -73,7 +74,7 @@ export function SidebarHeaderRow({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
[ThemedIcon, isActive, label, shortcutKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,15 +129,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||||
// strip so the hover highlight clears the strip's bottom separator.
|
// compact header entries sit tight against the workspace list below.
|
||||||
minHeight: 28,
|
minHeight: 36,
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[2],
|
||||||
// Match the project rows' inner padding so the icons align on one vertical
|
paddingHorizontal: theme.spacing[3],
|
||||||
// edge with the list below.
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
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],
|
||||||
|
// 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]).
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
},
|
||||||
buttonHovered: {
|
buttonHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
|
|||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||||
|
|
||||||
export type { SidebarWorkspaceTrailing };
|
export type { SidebarWorkspaceTrailing };
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,14 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
|
|||||||
import { type ReactNode, useMemo } from "react";
|
import { type ReactNode, useMemo } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
|
||||||
type ButtonControlSize,
|
|
||||||
buttonIconSize,
|
|
||||||
createControlGeometry,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import { hexColorWithAlpha } from "@/utils/color";
|
|
||||||
|
|
||||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||||
export type AlertSize = ButtonControlSize;
|
|
||||||
|
|
||||||
export interface AlertProps {
|
export interface AlertProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
variant?: AlertVariant;
|
variant?: AlertVariant;
|
||||||
size?: AlertSize;
|
icon?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -32,93 +25,50 @@ export function Alert({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "md",
|
icon,
|
||||||
children,
|
children,
|
||||||
testID,
|
testID,
|
||||||
}: AlertProps) {
|
}: AlertProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const accentColor = resolveAccentColor(variant, theme);
|
const accentColor = resolveAccentColor(variant, theme);
|
||||||
const borderColor =
|
const borderColor = variant === "success" ? theme.colors.border : accentColor;
|
||||||
variant === "success" || !accentColor
|
|
||||||
? theme.colors.border
|
|
||||||
: hexColorWithAlpha(accentColor, 0.5);
|
|
||||||
|
|
||||||
const sized = resolveSizeStyles(size);
|
const containerStyle = useMemo(
|
||||||
const containerStyle = [styles.container, sized.container, { borderColor }];
|
() => [styles.container, borderColor ? { borderColor } : null],
|
||||||
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
|
[borderColor],
|
||||||
const iconSize = buttonIconSize[size];
|
);
|
||||||
|
|
||||||
|
const titleStyle = useMemo(
|
||||||
|
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||||
|
[accentColor],
|
||||||
|
);
|
||||||
|
|
||||||
const resolvedIcon = useMemo(() => {
|
const resolvedIcon = useMemo(() => {
|
||||||
|
if (icon !== undefined) return icon;
|
||||||
if (variant === "default") return null;
|
if (variant === "default") return null;
|
||||||
const Icon = VARIANT_ICON[variant];
|
const Icon = VARIANT_ICON[variant];
|
||||||
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
|
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||||
}, [variant, theme, accentColor, iconSize]);
|
}, [icon, variant, theme, accentColor]);
|
||||||
|
|
||||||
let descriptionContent: ReactNode = null;
|
const hasDescription = description != null && description !== "";
|
||||||
if (typeof description === "string" && description !== "") {
|
|
||||||
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
|
|
||||||
} else if (description != null && description !== "") {
|
|
||||||
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
|
|
||||||
}
|
|
||||||
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
|
|
||||||
const belowLead = title ? descriptionContent : null;
|
|
||||||
const hasBody = belowLead !== null || Boolean(children);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||||
<View style={[styles.lead, sized.lead]}>
|
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||||
{resolvedIcon ? (
|
<View style={styles.body}>
|
||||||
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
|
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||||
|
{hasDescription && typeof description === "string" ? (
|
||||||
|
<Text style={styles.description}>{description}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
|
{hasDescription && typeof description !== "string" ? (
|
||||||
|
<View style={styles.descriptionSlot}>{description}</View>
|
||||||
|
) : null}
|
||||||
|
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||||
</View>
|
</View>
|
||||||
{hasBody ? (
|
|
||||||
<View style={resolvedIcon ? sized.indent : null}>
|
|
||||||
{belowLead}
|
|
||||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSizeStyles(size: AlertSize) {
|
|
||||||
if (size === "xs") {
|
|
||||||
return {
|
|
||||||
container: styles.containerXs,
|
|
||||||
text: styles.textXs,
|
|
||||||
lead: styles.leadXs,
|
|
||||||
iconSlot: styles.iconSlotXs,
|
|
||||||
indent: styles.indentXs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (size === "sm") {
|
|
||||||
return {
|
|
||||||
container: styles.containerSm,
|
|
||||||
text: styles.textSm,
|
|
||||||
lead: styles.leadSm,
|
|
||||||
iconSlot: styles.iconSlotSm,
|
|
||||||
indent: styles.indentSm,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (size === "lg") {
|
|
||||||
return {
|
|
||||||
container: styles.containerLg,
|
|
||||||
text: styles.textLg,
|
|
||||||
lead: styles.leadLg,
|
|
||||||
iconSlot: styles.iconSlotLg,
|
|
||||||
indent: styles.indentLg,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
container: styles.containerMd,
|
|
||||||
text: styles.textMd,
|
|
||||||
lead: styles.leadMd,
|
|
||||||
iconSlot: styles.iconSlotMd,
|
|
||||||
indent: styles.indentMd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAccentColor(
|
function resolveAccentColor(
|
||||||
variant: AlertVariant,
|
variant: AlertVariant,
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
@@ -130,63 +80,43 @@ function resolveAccentColor(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => ({
|
||||||
const { alert } = createControlGeometry(theme);
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
return {
|
alignItems: "flex-start",
|
||||||
container: {
|
gap: theme.spacing[3],
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
},
|
borderRadius: theme.borderRadius.xl,
|
||||||
containerXs: alert.xs.container,
|
paddingVertical: theme.spacing[3],
|
||||||
containerSm: alert.sm.container,
|
paddingHorizontal: theme.spacing[4],
|
||||||
containerMd: alert.md.container,
|
},
|
||||||
containerLg: alert.lg.container,
|
iconSlot: {
|
||||||
lead: {
|
paddingTop: 2,
|
||||||
flexDirection: "row",
|
},
|
||||||
alignItems: "center",
|
body: {
|
||||||
},
|
flex: 1,
|
||||||
iconSlot: {
|
minWidth: 0,
|
||||||
alignItems: "center",
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
leadText: {
|
title: {
|
||||||
flex: 1,
|
color: theme.colors.foreground,
|
||||||
minWidth: 0,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
fontWeight: theme.fontWeight.medium,
|
||||||
leadXs: alert.xs.lead,
|
},
|
||||||
leadSm: alert.sm.lead,
|
description: {
|
||||||
leadMd: alert.md.lead,
|
color: theme.colors.foregroundMuted,
|
||||||
leadLg: alert.lg.lead,
|
fontSize: theme.fontSize.sm,
|
||||||
iconSlotXs: alert.xs.iconSlot,
|
},
|
||||||
iconSlotSm: alert.sm.iconSlot,
|
descriptionSlot: {
|
||||||
iconSlotMd: alert.md.iconSlot,
|
flexShrink: 1,
|
||||||
iconSlotLg: alert.lg.iconSlot,
|
minWidth: 0,
|
||||||
indentXs: alert.xs.indent,
|
gap: theme.spacing[2],
|
||||||
indentSm: alert.sm.indent,
|
},
|
||||||
indentMd: alert.md.indent,
|
actions: {
|
||||||
indentLg: alert.lg.indent,
|
flexDirection: "row",
|
||||||
textXs: alert.xs.text,
|
gap: theme.spacing[2],
|
||||||
textSm: alert.sm.text,
|
marginTop: theme.spacing[2],
|
||||||
textMd: alert.md.text,
|
},
|
||||||
textLg: alert.lg.text,
|
}));
|
||||||
title: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
descriptionSlot: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[2],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buttonControlHeight,
|
buttonControlHeight,
|
||||||
buttonIconSize,
|
|
||||||
createControlGeometry,
|
createControlGeometry,
|
||||||
getControlInteractionPhase,
|
getControlInteractionPhase,
|
||||||
} from "@/components/ui/control-geometry";
|
} from "@/components/ui/control-geometry";
|
||||||
@@ -12,7 +11,6 @@ const theme = {
|
|||||||
md: 6,
|
md: 6,
|
||||||
lg: 8,
|
lg: 8,
|
||||||
xl: 12,
|
xl: 12,
|
||||||
"2xl": 16,
|
|
||||||
full: 9999,
|
full: 9999,
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
@@ -32,13 +30,10 @@ const theme = {
|
|||||||
},
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
0: 0,
|
0: 0,
|
||||||
0.5: 2,
|
|
||||||
1: 4,
|
|
||||||
2: 8,
|
2: 8,
|
||||||
3: 12,
|
3: 12,
|
||||||
4: 16,
|
4: 16,
|
||||||
6: 24,
|
6: 24,
|
||||||
8: 32,
|
|
||||||
},
|
},
|
||||||
} as unknown as Theme;
|
} as unknown as Theme;
|
||||||
|
|
||||||
@@ -162,41 +157,4 @@ describe("control geometry", () => {
|
|||||||
geometry.buttonMd.paddingHorizontal,
|
geometry.buttonMd.paddingHorizontal,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives alert title and description one font size and a tight gap per size", () => {
|
|
||||||
const { alert } = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(alert.xs.text).toEqual({ fontSize: 12 });
|
|
||||||
expect(alert.sm.text).toEqual({ fontSize: 14 });
|
|
||||||
expect(alert.md.text).toEqual(alert.sm.text);
|
|
||||||
expect(alert.lg.text).toEqual(alert.sm.text);
|
|
||||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
|
||||||
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts alert text below the lead row at the lead text's left edge", () => {
|
|
||||||
const { alert } = createControlGeometry(theme);
|
|
||||||
|
|
||||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
|
||||||
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
|
|
||||||
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
|
|
||||||
const { alert } = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(alert.sm.container).toMatchObject({
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 16,
|
|
||||||
});
|
|
||||||
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
|
|
||||||
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
|
|
||||||
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
|
|
||||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
|
||||||
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,77 +87,6 @@ function fieldVerticalPadding(
|
|||||||
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
|
|
||||||
* The icon and the first text sit in one centered lead row; everything below is indented
|
|
||||||
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
|
|
||||||
* description use one font size; only the container gap separates them.
|
|
||||||
*/
|
|
||||||
function createAlertGeometry(theme: Theme) {
|
|
||||||
function alertSize(input: {
|
|
||||||
size: ButtonControlSize;
|
|
||||||
fontSize: number;
|
|
||||||
paddingVertical: number;
|
|
||||||
paddingHorizontal: number;
|
|
||||||
borderRadius: number;
|
|
||||||
iconGap: number;
|
|
||||||
textGap: number;
|
|
||||||
}) {
|
|
||||||
const iconSize = buttonIconSize[input.size];
|
|
||||||
return {
|
|
||||||
container: {
|
|
||||||
paddingVertical: input.paddingVertical,
|
|
||||||
paddingHorizontal: input.paddingHorizontal,
|
|
||||||
borderRadius: input.borderRadius,
|
|
||||||
gap: input.textGap,
|
|
||||||
},
|
|
||||||
lead: { gap: input.iconGap },
|
|
||||||
iconSlot: { width: iconSize },
|
|
||||||
indent: { marginLeft: iconSize + input.iconGap },
|
|
||||||
text: { fontSize: input.fontSize },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
xs: alertSize({
|
|
||||||
size: "xs",
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.xl,
|
|
||||||
iconGap: theme.spacing[2],
|
|
||||||
textGap: 0,
|
|
||||||
}),
|
|
||||||
sm: alertSize({
|
|
||||||
size: "sm",
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
iconGap: theme.spacing[3],
|
|
||||||
textGap: theme.spacing[0.5],
|
|
||||||
}),
|
|
||||||
md: alertSize({
|
|
||||||
size: "md",
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
paddingVertical: theme.spacing[4],
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
iconGap: theme.spacing[3],
|
|
||||||
textGap: theme.spacing[0.5],
|
|
||||||
}),
|
|
||||||
lg: alertSize({
|
|
||||||
size: "lg",
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
paddingVertical: theme.spacing[6],
|
|
||||||
paddingHorizontal: theme.spacing[8],
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
iconGap: theme.spacing[4],
|
|
||||||
textGap: theme.spacing[1],
|
|
||||||
}),
|
|
||||||
} satisfies Record<ButtonControlSize, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getControlInteractionPhase(
|
export function getControlInteractionPhase(
|
||||||
state: ControlInteractionState,
|
state: ControlInteractionState,
|
||||||
): ControlInteractionPhase {
|
): ControlInteractionPhase {
|
||||||
@@ -218,7 +147,6 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: fieldTextMdLineHeight,
|
lineHeight: fieldTextMdLineHeight,
|
||||||
};
|
};
|
||||||
const alert = createAlertGeometry(theme);
|
|
||||||
const switchControl = {
|
const switchControl = {
|
||||||
minHeight: CONTROL_HEIGHTS.compact,
|
minHeight: CONTROL_HEIGHTS.compact,
|
||||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||||
@@ -228,22 +156,22 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
buttonXs: {
|
buttonXs: {
|
||||||
minHeight: buttonControlHeight.xs,
|
minHeight: buttonControlHeight.xs,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
buttonSm: {
|
buttonSm: {
|
||||||
minHeight: buttonControlHeight.sm,
|
minHeight: buttonControlHeight.sm,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
buttonMd: {
|
buttonMd: {
|
||||||
minHeight: buttonControlHeight.md,
|
minHeight: buttonControlHeight.md,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
buttonLg: {
|
buttonLg: {
|
||||||
minHeight: buttonControlHeight.lg,
|
minHeight: buttonControlHeight.lg,
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius.xl,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -251,7 +179,6 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
buttonTextXs: {
|
buttonTextXs: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
alert,
|
|
||||||
formTextInputSm: {
|
formTextInputSm: {
|
||||||
...fieldControlSm,
|
...fieldControlSm,
|
||||||
...fieldTextSm,
|
...fieldTextSm,
|
||||||
@@ -306,17 +233,17 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
segmentedSegmentXs: {
|
segmentedSegmentXs: {
|
||||||
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
segmentedSegmentSm: {
|
segmentedSegmentSm: {
|
||||||
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
segmentedSegmentMd: {
|
segmentedSegmentMd: {
|
||||||
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
segmentedLabelXs: {
|
segmentedLabelXs: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -1,104 +1,48 @@
|
|||||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { Text, View, type ViewStyle } from "react-native";
|
import { View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import {
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
buttonIconSize,
|
|
||||||
createControlGeometry,
|
|
||||||
type ButtonControlSize,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
|
|
||||||
|
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
|
||||||
|
children?: ReactNode;
|
||||||
|
chevron?: ReactNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
const chevronColorMapping = (theme: Theme) => ({
|
const chevronColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
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({
|
export function DropdownTrigger({
|
||||||
children,
|
children,
|
||||||
leading,
|
chevron,
|
||||||
size = "sm",
|
|
||||||
disabled,
|
|
||||||
...props
|
...props
|
||||||
}: DropdownTriggerProps): ReactElement {
|
}: 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 (
|
return (
|
||||||
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
|
<DropdownMenuTrigger {...props}>
|
||||||
{leading}
|
<View style={styles.row}>
|
||||||
<Text style={labelStyle} numberOfLines={1}>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
{chevron !== null &&
|
||||||
<View style={styles.chevron}>
|
(chevron ?? (
|
||||||
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
|
<View style={styles.chevronContainer}>
|
||||||
|
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => ({
|
||||||
const geometry = createControlGeometry(theme);
|
row: {
|
||||||
return {
|
flexDirection: "row",
|
||||||
base: {
|
alignItems: "center",
|
||||||
flexDirection: "row",
|
gap: theme.spacing[1],
|
||||||
alignItems: "center",
|
},
|
||||||
gap: theme.spacing[2],
|
chevronContainer: {
|
||||||
borderWidth: 1,
|
transform: [{ translateY: 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,8 +56,6 @@ interface ToolbarButtonCommonProps extends Omit<
|
|||||||
> {
|
> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
/** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */
|
|
||||||
tooltip?: ReactNode;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
shortcut?: ShortcutKey[][] | null;
|
shortcut?: ShortcutKey[][] | null;
|
||||||
@@ -78,7 +76,6 @@ type ToolbarButtonProps = ToolbarButtonCommonProps &
|
|||||||
export function ToolbarButton({
|
export function ToolbarButton({
|
||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
tooltip: tooltipBody,
|
|
||||||
selected = false,
|
selected = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
shortcut,
|
shortcut,
|
||||||
@@ -108,7 +105,7 @@ export function ToolbarButton({
|
|||||||
const tooltip = (
|
const tooltip = (
|
||||||
<TooltipContent side={tooltipSide} align="center" offset={8}>
|
<TooltipContent side={tooltipSide} align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
{tooltipBody ?? <Text style={styles.tooltipText}>{label}</Text>}
|
<Text style={styles.tooltipText}>{label}</Text>
|
||||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -32,32 +32,15 @@ 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", () => {
|
describe("StatusBadge", () => {
|
||||||
it("uses the neutral badge shell for the muted variant", () => {
|
it.each(["success", "warning", "error", "muted"] as const)(
|
||||||
const style = getComputedStyle(mountBadge("muted"));
|
"uses the semantic badge shell for the %s variant",
|
||||||
|
|
||||||
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) => {
|
(variant) => {
|
||||||
const badge = mountBadge(variant);
|
const badge = mountBadge(variant);
|
||||||
const text = badge.lastElementChild;
|
const style = getComputedStyle(badge);
|
||||||
if (!(text instanceof HTMLElement)) {
|
|
||||||
throw new Error("StatusBadge did not render its label");
|
|
||||||
}
|
|
||||||
const fill = parseColor(getComputedStyle(badge).backgroundColor);
|
|
||||||
|
|
||||||
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
|
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||||
expect(fill.alpha).toBeGreaterThan(0);
|
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||||
expect(fill.alpha).toBeLessThan(0.5);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ interface StatusBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ label, variant = "muted", leading }: 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(
|
const textStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.pillText,
|
styles.pillText,
|
||||||
@@ -31,7 +22,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={pillStyle}>
|
<View style={styles.pill}>
|
||||||
{leading}
|
{leading}
|
||||||
<Text style={textStyle}>{label}</Text>
|
<Text style={textStyle}>{label}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -50,18 +41,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
},
|
},
|
||||||
pillSuccess: {
|
|
||||||
backgroundColor: theme.colors.statusSuccessTint,
|
|
||||||
borderColor: "transparent",
|
|
||||||
},
|
|
||||||
pillWarning: {
|
|
||||||
backgroundColor: theme.colors.statusWarningTint,
|
|
||||||
borderColor: "transparent",
|
|
||||||
},
|
|
||||||
pillError: {
|
|
||||||
backgroundColor: theme.colors.statusDangerTint,
|
|
||||||
borderColor: "transparent",
|
|
||||||
},
|
|
||||||
pillText: {
|
pillText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
cwd={sourceDirectory}
|
cwd={sourceDirectory}
|
||||||
clearDraft={chatDraft.clear}
|
clearDraft={chatDraft.clear}
|
||||||
autoFocus
|
autoFocus
|
||||||
commandDraft={composerState?.commandDraft}
|
commandDraftConfig={composerState?.commandDraftConfig}
|
||||||
agentControls={agentControlsWithDisabled}
|
agentControls={agentControlsWithDisabled}
|
||||||
inputWrapperStyle={styles.composerInputWrapper}
|
inputWrapperStyle={styles.composerInputWrapper}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -198,15 +198,12 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
||||||
expect(getLatest().composerState?.commandDraft).toEqual({
|
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||||
status: "ready",
|
provider: "codex",
|
||||||
config: {
|
cwd: "/repo",
|
||||||
provider: "codex",
|
modeId: "auto",
|
||||||
cwd: "/repo",
|
model: "gpt-5.4",
|
||||||
modeId: "auto",
|
thinkingOptionId: "high",
|
||||||
model: "gpt-5.4",
|
|
||||||
thinkingOptionId: "high",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hydratedTextReplacement = getLatest().textReplacement;
|
const hydratedTextReplacement = getLatest().textReplacement;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||||
import {
|
import {
|
||||||
buildDraftCommandTarget,
|
buildDraftCommandConfig,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
@@ -124,55 +124,46 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildDraftCommandTarget", () => {
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
const codexSelection = {
|
it("returns undefined when cwd is empty", () => {
|
||||||
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(
|
expect(
|
||||||
buildDraftCommandTarget({
|
buildDraftCommandConfig({
|
||||||
selection: codexSelection,
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
cwd: " ",
|
cwd: " ",
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
).toEqual({ status: "needs-project" });
|
).toBeUndefined();
|
||||||
});
|
|
||||||
|
|
||||||
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", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDraftCommandTarget({
|
buildDraftCommandConfig({
|
||||||
selection: codexSelection,
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "auto",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
|
},
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
status: "ready",
|
provider: "codex",
|
||||||
config: {
|
cwd: "/repo",
|
||||||
provider: "codex",
|
modeId: "auto",
|
||||||
cwd: "/repo",
|
model: "gpt-5.4",
|
||||||
modeId: "auto",
|
thinkingOptionId: "high",
|
||||||
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 { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { TextReplacement } from "@/composer/types";
|
import type { TextReplacement } from "@/composer/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
import {
|
||||||
useAgentFormState,
|
useAgentFormState,
|
||||||
type CreateAgentInitialValues,
|
type CreateAgentInitialValues,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type DraftKeyInput,
|
type DraftKeyInput,
|
||||||
} from "@/composer/draft/input-draft-core";
|
} from "@/composer/draft/input-draft-core";
|
||||||
import {
|
import {
|
||||||
buildDraftCommandTarget,
|
buildDraftCommandConfig,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
type ProviderSelectionState,
|
type ProviderSelectionState,
|
||||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
|||||||
effectiveThinkingOptionId: string;
|
effectiveThinkingOptionId: string;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
agentControls: DraftAgentControlsProps;
|
agentControls: DraftAgentControlsProps;
|
||||||
commandDraft: DraftCommandTarget;
|
commandDraftConfig: DraftCommandConfig | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentInputDraft {
|
export interface AgentInputDraft {
|
||||||
@@ -287,16 +287,19 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
[applyProfileFeatureValues, formState],
|
[applyProfileFeatureValues, formState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const commandDraft = useMemo(
|
const commandDraftConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildDraftCommandTarget({
|
composerOptions
|
||||||
selection: providerSelection,
|
? buildDraftCommandConfig({
|
||||||
cwd: workingDir,
|
selection: providerSelection,
|
||||||
effectiveModelId,
|
cwd: workingDir,
|
||||||
effectiveThinkingOptionId,
|
effectiveModelId,
|
||||||
featureValues: draftFeatureValues,
|
effectiveThinkingOptionId,
|
||||||
}),
|
featureValues: draftFeatureValues,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
[
|
[
|
||||||
|
composerOptions,
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
draftFeatureValues,
|
draftFeatureValues,
|
||||||
@@ -322,10 +325,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
onSetFeature: setDraftFeatureValue,
|
onSetFeature: setDraftFeatureValue,
|
||||||
onApplyAgentProfile: applyDraftAgentProfile,
|
onApplyAgentProfile: applyDraftAgentProfile,
|
||||||
}),
|
}),
|
||||||
commandDraft,
|
commandDraftConfig,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
commandDraft,
|
commandDraftConfig,
|
||||||
composerOptions,
|
composerOptions,
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
@@ -355,6 +358,7 @@ export const __private__ = {
|
|||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
buildDraftCommandTarget,
|
buildDraftCommandConfig,
|
||||||
|
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||||
buildDraftAgentControls,
|
buildDraftAgentControls,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||||
onFocusInput={handleFocusInputCallback}
|
onFocusInput={handleFocusInputCallback}
|
||||||
commandDraft={composerState.commandDraft}
|
commandDraftConfig={composerState.commandDraftConfig}
|
||||||
agentControls={composerAgentControls}
|
agentControls={composerAgentControls}
|
||||||
isCompactLayout={isCompactComposerLayout}
|
isCompactLayout={isCompactComposerLayout}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
type DraftAgentControlsProps,
|
type DraftAgentControlsProps,
|
||||||
} from "@/composer/agent-controls";
|
} from "@/composer/agent-controls";
|
||||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||||
import { UsageComposerPill } from "@/usage";
|
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||||
@@ -62,7 +61,7 @@ import {
|
|||||||
} from "./input/input";
|
} from "./input/input";
|
||||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import {
|
import {
|
||||||
@@ -275,6 +274,7 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
|||||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
||||||
model: agent?.model ?? null,
|
model: agent?.model ?? null,
|
||||||
|
provider: agent?.provider ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -284,6 +284,8 @@ function renderContextWindowMeter(
|
|||||||
contextWindowUsedTokens: number | null,
|
contextWindowUsedTokens: number | null,
|
||||||
totalCostUsd: number | null,
|
totalCostUsd: number | null,
|
||||||
showPercentage: boolean,
|
showPercentage: boolean,
|
||||||
|
serverId: string,
|
||||||
|
provider: string | null,
|
||||||
pending: boolean,
|
pending: boolean,
|
||||||
glyphSize: number,
|
glyphSize: number,
|
||||||
): ReactElement | null {
|
): ReactElement | null {
|
||||||
@@ -297,6 +299,8 @@ function renderContextWindowMeter(
|
|||||||
usedTokens={contextWindowUsedTokens}
|
usedTokens={contextWindowUsedTokens}
|
||||||
totalCostUsd={totalCostUsd}
|
totalCostUsd={totalCostUsd}
|
||||||
showPercentage={showPercentage}
|
showPercentage={showPercentage}
|
||||||
|
serverId={serverId}
|
||||||
|
provider={provider}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
glyphSize={glyphSize}
|
glyphSize={glyphSize}
|
||||||
/>
|
/>
|
||||||
@@ -973,8 +977,8 @@ interface ComposerProps {
|
|||||||
autoFocusKey?: string;
|
autoFocusKey?: string;
|
||||||
/** Callback to expose a focus function to parent components (desktop only). */
|
/** Callback to expose a focus function to parent components (desktop only). */
|
||||||
onFocusInput?: (focus: () => void) => void;
|
onFocusInput?: (focus: () => void) => void;
|
||||||
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
commandDraft?: DraftCommandTarget;
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||||
onMessageSent?: () => void;
|
onMessageSent?: () => void;
|
||||||
onComposerHeightChange?: (height: number) => void;
|
onComposerHeightChange?: (height: number) => void;
|
||||||
@@ -1264,7 +1268,7 @@ function ComposerContentImpl({
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoFocusKey,
|
autoFocusKey,
|
||||||
onFocusInput,
|
onFocusInput,
|
||||||
commandDraft,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
onComposerHeightChange,
|
onComposerHeightChange,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
@@ -2081,6 +2085,8 @@ function ComposerContentImpl({
|
|||||||
contextWindowUsedTokens,
|
contextWindowUsedTokens,
|
||||||
agentState.totalCostUsd,
|
agentState.totalCostUsd,
|
||||||
false,
|
false,
|
||||||
|
serverId,
|
||||||
|
agentState.provider,
|
||||||
contextWindowPending,
|
contextWindowPending,
|
||||||
contextWindowMeterGlyphSize,
|
contextWindowMeterGlyphSize,
|
||||||
),
|
),
|
||||||
@@ -2088,18 +2094,15 @@ function ComposerContentImpl({
|
|||||||
contextWindowMaxTokens,
|
contextWindowMaxTokens,
|
||||||
contextWindowUsedTokens,
|
contextWindowUsedTokens,
|
||||||
agentState.totalCostUsd,
|
agentState.totalCostUsd,
|
||||||
|
serverId,
|
||||||
|
agentState.provider,
|
||||||
contextWindowPending,
|
contextWindowPending,
|
||||||
contextWindowMeterGlyphSize,
|
contextWindowMeterGlyphSize,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const beforeVoiceContent = useMemo(
|
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(
|
const hasGithubAttachment = useMemo(
|
||||||
@@ -2346,7 +2349,7 @@ function ComposerContentImpl({
|
|||||||
setUserInput: replaceUserInput,
|
setUserInput: replaceUserInput,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
draft: commandDraft,
|
draftConfig: commandDraftConfig,
|
||||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||||
onClientSlashCommand: runClientSlashCommand,
|
onClientSlashCommand: runClientSlashCommand,
|
||||||
pluginClientSlashCommands,
|
pluginClientSlashCommands,
|
||||||
@@ -2355,7 +2358,7 @@ function ComposerContentImpl({
|
|||||||
replaceUserInput,
|
replaceUserInput,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
commandDraft,
|
commandDraftConfig,
|
||||||
buildOutgoingAttachments,
|
buildOutgoingAttachments,
|
||||||
attachments,
|
attachments,
|
||||||
runClientSlashCommand,
|
runClientSlashCommand,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
|
|||||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||||
import {
|
import {
|
||||||
selectAgentTimelineState,
|
selectAgentTimelineState,
|
||||||
toDaemonServerInfo,
|
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
@@ -319,6 +318,24 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
[serverId],
|
[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(() => {
|
useEffect(() => {
|
||||||
const unregister = voiceRuntime?.registerSession({
|
const unregister = voiceRuntime?.registerSession({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -551,7 +568,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||||
if (serverInfo) {
|
if (serverInfo) {
|
||||||
updateSessionServerInfo(serverId, toDaemonServerInfo(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 } : {}),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={isClearing}
|
loading={isClearing}
|
||||||
disabled={isClearing}
|
disabled={isClearing}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export function DesktopNotificationsSection() {
|
|||||||
</View>
|
</View>
|
||||||
{testNotificationState.status === "success" ? (
|
{testNotificationState.status === "success" ? (
|
||||||
<Alert
|
<Alert
|
||||||
size="sm"
|
|
||||||
variant="success"
|
variant="success"
|
||||||
title={t("settings.notifications.sentTitle")}
|
title={t("settings.notifications.sentTitle")}
|
||||||
description={t("settings.notifications.sentDescription")}
|
description={t("settings.notifications.sentDescription")}
|
||||||
@@ -140,7 +139,6 @@ export function DesktopNotificationsSection() {
|
|||||||
) : null}
|
) : null}
|
||||||
{testNotificationState.status === "error" ? (
|
{testNotificationState.status === "error" ? (
|
||||||
<Alert
|
<Alert
|
||||||
size="sm"
|
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.notifications.sendFailedTitle")}
|
title={t("settings.notifications.sendFailedTitle")}
|
||||||
description={testNotificationState.message}
|
description={testNotificationState.message}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
|||||||
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Alert size="sm" variant="error" description={message}>
|
<Alert variant="error" description={message}>
|
||||||
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
{t("pairing.device.retry")}
|
{t("pairing.device.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,11 +197,9 @@ function RelayConsent(props: PairDeviceBodyProps) {
|
|||||||
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{props.enableError ? (
|
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
|
||||||
<Alert size="sm" variant="error" description={props.enableError.message} />
|
|
||||||
) : null}
|
|
||||||
{!props.canConfigureRelay ? (
|
{!props.canConfigureRelay ? (
|
||||||
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
|
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
||||||
@@ -264,7 +262,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
|||||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
|
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { HostConnection } from "@/types/host-connection";
|
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
|
||||||
|
|
||||||
function connectionListen(connection: HostConnection): string | null {
|
|
||||||
switch (connection.type) {
|
|
||||||
case "directTcp":
|
|
||||||
return connection.endpoint;
|
|
||||||
case "directSocket":
|
|
||||||
return `unix://${connection.path}`;
|
|
||||||
case "directPipe":
|
|
||||||
return `pipe://${connection.path}`;
|
|
||||||
case "remoteSsh":
|
|
||||||
case "relay":
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop main checks that this target matches the live desktop-managed daemon.
|
|
||||||
// A plain browser has no bridge and falls back to its saved host password.
|
|
||||||
export async function readDesktopManagedLocalCredential(
|
|
||||||
connection: HostConnection,
|
|
||||||
invoke: (listen: string) => Promise<unknown> = async (listen) =>
|
|
||||||
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const listen = connectionListen(connection);
|
|
||||||
if (!listen) return undefined;
|
|
||||||
const credential = await invoke(listen);
|
|
||||||
return typeof credential === "string" ? credential : undefined;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { defaultHostAppearance } from "@/hosts/appearance";
|
|||||||
function makeHost(): HostProfile {
|
function makeHost(): HostProfile {
|
||||||
return {
|
return {
|
||||||
serverId: "srv-secret",
|
serverId: "srv-secret",
|
||||||
password: "tcp-password",
|
|
||||||
label: "Secret host",
|
label: "Secret host",
|
||||||
appearance: defaultHostAppearance(),
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
@@ -24,6 +23,7 @@ function makeHost(): HostProfile {
|
|||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "secret.example.test:6767",
|
endpoint: "secret.example.test:6767",
|
||||||
useTls: true,
|
useTls: true,
|
||||||
|
password: "tcp-password",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "relay:relay.secret.test:443",
|
id: "relay:relay.secret.test:443",
|
||||||
|
|||||||
@@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]):
|
|||||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
if (host.password) values.add(host.password);
|
|
||||||
for (const connection of host.connections) {
|
for (const connection of host.connections) {
|
||||||
values.add(connection.id);
|
values.add(connection.id);
|
||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
values.add(connection.endpoint);
|
values.add(connection.endpoint);
|
||||||
|
if (connection.password) values.add(connection.password);
|
||||||
} else if (connection.type === "relay") {
|
} else if (connection.type === "relay") {
|
||||||
values.add(connection.relayEndpoint);
|
values.add(connection.relayEndpoint);
|
||||||
values.add(connection.daemonPublicKeyB64);
|
values.add(connection.daemonPublicKeyB64);
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { CommitGraphNode } from "./commit-graph-node";
|
import { CommitGraphNode } from "./commit-graph-node";
|
||||||
|
|
||||||
interface CommitRowProps {
|
interface CommitRowProps {
|
||||||
commit: ClassifiedCheckoutCommit;
|
commit: ClassifiedCheckoutCommit;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
|
now: Date;
|
||||||
onCommitPress: (sha: string) => void;
|
onCommitPress: (sha: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export const CommitRow = memo(function CommitRow({
|
|||||||
commit,
|
commit,
|
||||||
isFirst,
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
|
now,
|
||||||
onCommitPress,
|
onCommitPress,
|
||||||
}: CommitRowProps) {
|
}: CommitRowProps) {
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -47,7 +49,7 @@ export const CommitRow = memo(function CommitRow({
|
|||||||
{commit.subject}
|
{commit.subject}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<CommitTimestamp authorDate={commit.authorDate} />
|
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||||
<View style={styles.caret}>
|
<View style={styles.caret}>
|
||||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
@@ -55,11 +57,6 @@ 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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
import { treeRowPaddingLeft } from "@/components/tree-primitives";
|
import { treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||||
@@ -39,9 +40,11 @@ function CommitsSectionSkeleton() {
|
|||||||
|
|
||||||
function CommitsSectionContent({
|
function CommitsSectionContent({
|
||||||
query,
|
query,
|
||||||
|
now,
|
||||||
onCommitPress,
|
onCommitPress,
|
||||||
}: {
|
}: {
|
||||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||||
|
now: Date;
|
||||||
onCommitPress: (sha: string) => void;
|
onCommitPress: (sha: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -74,6 +77,7 @@ function CommitsSectionContent({
|
|||||||
commit={commit}
|
commit={commit}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
isLast={index === workspaceCommits.length - 1}
|
isLast={index === workspaceCommits.length - 1}
|
||||||
|
now={now}
|
||||||
onCommitPress={onCommitPress}
|
onCommitPress={onCommitPress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -90,6 +94,9 @@ export function CommitsSection({
|
|||||||
}: CommitsSectionProps) {
|
}: CommitsSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const isPanelActive = useRetainedPanelActive();
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||||
const query = useCheckoutCommitsQuery({
|
const query = useCheckoutCommitsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -97,9 +104,20 @@ export function CommitsSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleToggleSection = useCallback(() => {
|
const handleToggleSection = useCallback(() => {
|
||||||
|
if (collapsed) {
|
||||||
|
setNow(new Date());
|
||||||
|
}
|
||||||
onCollapsedChange?.(!collapsed);
|
onCollapsedChange?.(!collapsed);
|
||||||
}, [collapsed, onCollapsedChange]);
|
}, [collapsed, onCollapsedChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collapsed || !isPanelActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [collapsed, isPanelActive]);
|
||||||
|
|
||||||
const headerChevronStyle = useMemo(
|
const headerChevronStyle = useMemo(
|
||||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||||
[collapsed],
|
[collapsed],
|
||||||
@@ -142,7 +160,9 @@ export function CommitsSection({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
{collapsed ? null : (
|
||||||
|
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useAgentCommandsQuery,
|
useAgentCommandsQuery,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
type DraftCommandConfig,
|
type DraftCommandConfig,
|
||||||
type DraftCommandTarget,
|
|
||||||
} from "./use-agent-commands-query";
|
} from "./use-agent-commands-query";
|
||||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||||
import { useAutocomplete } from "./use-autocomplete";
|
import { useAutocomplete } from "./use-autocomplete";
|
||||||
@@ -35,7 +34,7 @@ interface UseAgentAutocompleteInput {
|
|||||||
setUserInput: (nextValue: string) => void;
|
setUserInput: (nextValue: string) => void;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
draft?: DraftCommandTarget;
|
draftConfig?: DraftCommandConfig;
|
||||||
onAutocompleteApplied?: () => void;
|
onAutocompleteApplied?: () => void;
|
||||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||||
canExecuteClientSlashCommand?: boolean;
|
canExecuteClientSlashCommand?: boolean;
|
||||||
@@ -118,13 +117,18 @@ type AvailableCommand =
|
|||||||
| { source: "plugin"; command: PluginClientSlashCommand }
|
| { source: "plugin"; command: PluginClientSlashCommand }
|
||||||
| { source: "provider"; command: AgentSlashCommand };
|
| { source: "provider"; command: AgentSlashCommand };
|
||||||
|
|
||||||
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
|
function normalizeDraftCommandConfig(
|
||||||
if (draft?.status !== "ready") {
|
draftConfig?: DraftCommandConfig,
|
||||||
|
): DraftCommandConfig | undefined {
|
||||||
|
if (!draftConfig) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftConfig = draft.config;
|
|
||||||
const cwd = draftConfig.cwd.trim();
|
const cwd = draftConfig.cwd.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||||
const model = draftConfig.model?.trim() ?? "";
|
const model = draftConfig.model?.trim() ?? "";
|
||||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||||
@@ -333,35 +337,6 @@ function resolveAutocompleteErrorMessage(args: {
|
|||||||
return undefined;
|
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 {
|
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
@@ -370,7 +345,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
setUserInput,
|
setUserInput,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
draft,
|
draftConfig,
|
||||||
onAutocompleteApplied,
|
onAutocompleteApplied,
|
||||||
onClientSlashCommand,
|
onClientSlashCommand,
|
||||||
canExecuteClientSlashCommand,
|
canExecuteClientSlashCommand,
|
||||||
@@ -405,10 +380,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fileFilterQuery]);
|
}, [fileFilterQuery]);
|
||||||
|
|
||||||
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
|
const normalizedDraftConfig = useMemo(
|
||||||
() => resolveDraftCommandContext(draft),
|
() => normalizeDraftCommandConfig(draftConfig),
|
||||||
[draft],
|
[draftConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||||
|
const queryDraftConfig = normalizedDraftConfig;
|
||||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||||
|
|
||||||
const agentCwd = useSessionStore(
|
const agentCwd = useSessionStore(
|
||||||
@@ -432,14 +410,17 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
autocompleteCwd,
|
autocompleteCwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandsQuery = useAgentCommandsQuery({
|
const {
|
||||||
|
commands,
|
||||||
|
isLoading: isCommandsLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
enabled: mode === "command" && canLoadCommands,
|
||||||
draftConfig: queryDraftConfig,
|
draftConfig: queryDraftConfig,
|
||||||
});
|
});
|
||||||
const { commands, isError, error } = commandsQuery;
|
|
||||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
|
||||||
|
|
||||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||||
|
|
||||||
@@ -606,7 +587,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
mode === "file"
|
mode === "file"
|
||||||
? t("agentAutocomplete.searchingWorkspace")
|
? t("agentAutocomplete.searchingWorkspace")
|
||||||
: t("agentAutocomplete.loadingCommands");
|
: t("agentAutocomplete.loadingCommands");
|
||||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
const emptyText =
|
||||||
|
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isVisible,
|
isVisible,
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ export interface AgentSlashCommand {
|
|||||||
|
|
||||||
export type DraftCommandConfig = AgentCommandsDraftConfig;
|
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 {
|
interface ListAgentCommandsOptions {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
draftConfig?: DraftCommandConfig;
|
draftConfig?: DraftCommandConfig;
|
||||||
|
|||||||
+5
-17
@@ -1,11 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
||||||
import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time";
|
import { describeCompactTimeAgo } 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.
|
* A compact relative timestamp that keeps itself current.
|
||||||
@@ -24,14 +19,7 @@ export function useTimeAgo(date: Date | null): string {
|
|||||||
* never changes again.
|
* never changes again.
|
||||||
*/
|
*/
|
||||||
export function useCompactTimeAgo(date: Date | null): string {
|
export function useCompactTimeAgo(date: Date | null): string {
|
||||||
return useRelativeTimeLabel(date, describeCompactTimeAgo);
|
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// 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.
|
// depending on identity would tear down and rebuild the subscription for an unchanged time.
|
||||||
@@ -44,13 +32,13 @@ function useRelativeTimeLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = new Date(time);
|
const source = new Date(time);
|
||||||
let current = describe(source);
|
let current = describeCompactTimeAgo(source);
|
||||||
setLabel(current.label);
|
setLabel(current.label);
|
||||||
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
const handleTick = () => {
|
const handleTick = () => {
|
||||||
const next = describe(source);
|
const next = describeCompactTimeAgo(source);
|
||||||
if (next.label !== current.label) {
|
if (next.label !== current.label) {
|
||||||
setLabel(next.label);
|
setLabel(next.label);
|
||||||
}
|
}
|
||||||
@@ -74,7 +62,7 @@ function useRelativeTimeLabel(
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [time, describe]);
|
}, [time]);
|
||||||
|
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
@@ -1522,8 +1522,6 @@ export const ar: TranslationResources = {
|
|||||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||||
noCommands: "لم يتم العثور على أي أوامر",
|
noCommands: "لم يتم العثور على أي أوامر",
|
||||||
failedToLoad: "فشل التحميل",
|
failedToLoad: "فشل التحميل",
|
||||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
|
||||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "تعذر تحميل السجل الأقدم",
|
failed: "تعذر تحميل السجل الأقدم",
|
||||||
@@ -1615,10 +1613,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
hostPassword: {
|
|
||||||
title: "كلمة المرور لـ {{host}}",
|
|
||||||
label: "كلمة مرور المضيف",
|
|
||||||
},
|
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "إضافة اتصال",
|
title: "إضافة اتصال",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -1955,11 +1949,8 @@ export const ar: TranslationResources = {
|
|||||||
groupInfo: "حول{{title}}",
|
groupInfo: "حول{{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "عام",
|
general: "عام",
|
||||||
chat: "الدردشة",
|
|
||||||
appearance: "مظهر",
|
appearance: "مظهر",
|
||||||
sidebar: "الشريط الجانبي",
|
layout: en.settings.sections.layout,
|
||||||
terminal: "الطرفية",
|
|
||||||
browser: "المتصفح",
|
|
||||||
editor: "المحرر",
|
editor: "المحرر",
|
||||||
shortcuts: "الاختصارات",
|
shortcuts: "الاختصارات",
|
||||||
integrations: "التكامل",
|
integrations: "التكامل",
|
||||||
@@ -2018,7 +2009,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "عام",
|
title: "عام",
|
||||||
sending: "الإرسال",
|
|
||||||
browserData: {
|
browserData: {
|
||||||
title: "بيانات المتصفح",
|
title: "بيانات المتصفح",
|
||||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||||
@@ -2046,6 +2036,8 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
|
label: "عناوين URL للخدمة",
|
||||||
|
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
|
||||||
options: {
|
options: {
|
||||||
ask: "بسأل",
|
ask: "بسأل",
|
||||||
inApp: "في Paseo",
|
inApp: "في Paseo",
|
||||||
@@ -2064,6 +2056,7 @@ export const ar: TranslationResources = {
|
|||||||
toolCallDetail: {
|
toolCallDetail: {
|
||||||
label: "عرض استدعاءات الأدوات",
|
label: "عرض استدعاءات الأدوات",
|
||||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||||
|
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||||
options: {
|
options: {
|
||||||
overview: "ملخص",
|
overview: "ملخص",
|
||||||
detailed: "التفاصيل الكاملة",
|
detailed: "التفاصيل الكاملة",
|
||||||
@@ -2301,9 +2294,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
password: {
|
|
||||||
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "المظهر",
|
title: "المظهر",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user