mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5236c00d8 | ||
|
|
d1443064db | ||
|
|
9f387873b0 | ||
|
|
12cd5345d2 | ||
|
|
2c6d4e5283 | ||
|
|
006bae2897 | ||
|
|
437aef1a7b | ||
|
|
b44e7b0b90 | ||
|
|
09f87df1b2 | ||
|
|
b013e0d428 | ||
|
|
c938e1cf44 | ||
|
|
0c18144967 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+15
-6
@@ -195,17 +195,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
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-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
|
||||||
|
|||||||
Generated
+27
-60
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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",
|
||||||
@@ -6334,10 +6333,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
|
||||||
@@ -36415,7 +36410,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/server": "0.10.0",
|
"@getpaseo/server": "0.10.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37733,10 +37728,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@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.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"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.10.1",
|
||||||
"@getpaseo/highlight": "0.10.0",
|
"@getpaseo/highlight": "0.10.1",
|
||||||
"@getpaseo/plugin": "0.10.0",
|
"@getpaseo/plugin": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"@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.10.1",
|
||||||
"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.10.1",
|
||||||
"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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>,
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -74,7 +74,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> {
|
||||||
|
|||||||
@@ -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.10.1",
|
||||||
"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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -860,7 +860,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 +892,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,10 +0,0 @@
|
|||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { UsageScreen } from "@/usage";
|
|
||||||
|
|
||||||
export default function UsageRoute() {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<UsageScreen />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: "تعذر تحميل السجل الأقدم",
|
||||||
|
|||||||
@@ -1547,8 +1547,6 @@ export const en = {
|
|||||||
noFiles: "No files or directories found",
|
noFiles: "No files or directories found",
|
||||||
noCommands: "No commands found",
|
noCommands: "No commands found",
|
||||||
failedToLoad: "Failed to load",
|
failedToLoad: "Failed to load",
|
||||||
chooseProjectForCommands: "Choose a project to see commands",
|
|
||||||
chooseModelForCommands: "Select a model to see commands",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "Couldn't load older history",
|
failed: "Couldn't load older history",
|
||||||
|
|||||||
@@ -1568,8 +1568,6 @@ export const es: TranslationResources = {
|
|||||||
noFiles: "No se encontraron archivos ni directorios",
|
noFiles: "No se encontraron archivos ni directorios",
|
||||||
noCommands: "No se encontraron comandos",
|
noCommands: "No se encontraron comandos",
|
||||||
failedToLoad: "No se pudo cargar",
|
failedToLoad: "No se pudo cargar",
|
||||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
|
||||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "No se pudo cargar el historial anterior",
|
failed: "No se pudo cargar el historial anterior",
|
||||||
|
|||||||
@@ -1572,8 +1572,6 @@ export const fr: TranslationResources = {
|
|||||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||||
noCommands: "Aucune commande trouvée",
|
noCommands: "Aucune commande trouvée",
|
||||||
failedToLoad: "Échec du chargement",
|
failedToLoad: "Échec du chargement",
|
||||||
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
|
|
||||||
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "Impossible de charger l'ancien historique",
|
failed: "Impossible de charger l'ancien historique",
|
||||||
|
|||||||
@@ -1539,8 +1539,6 @@ export const ja: TranslationResources = {
|
|||||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||||
noCommands: "コマンドが見つかりません",
|
noCommands: "コマンドが見つかりません",
|
||||||
failedToLoad: "読み込みに失敗しました",
|
failedToLoad: "読み込みに失敗しました",
|
||||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
|
||||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "古い履歴を読み込めませんでした",
|
failed: "古い履歴を読み込めませんでした",
|
||||||
|
|||||||
@@ -1532,8 +1532,6 @@ export const ko: TranslationResources = {
|
|||||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||||
noCommands: "명령을 찾을 수 없습니다",
|
noCommands: "명령을 찾을 수 없습니다",
|
||||||
failedToLoad: "불러오지 못했습니다",
|
failedToLoad: "불러오지 못했습니다",
|
||||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
|
||||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "이전 기록을 불러올 수 없습니다",
|
failed: "이전 기록을 불러올 수 없습니다",
|
||||||
|
|||||||
@@ -1553,8 +1553,6 @@ export const ptBR: TranslationResources = {
|
|||||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||||
noCommands: "Nenhum comando encontrado",
|
noCommands: "Nenhum comando encontrado",
|
||||||
failedToLoad: "Falha ao carregar",
|
failedToLoad: "Falha ao carregar",
|
||||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
|
||||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "Não foi possível carregar o histórico mais antigo",
|
failed: "Não foi possível carregar o histórico mais antigo",
|
||||||
|
|||||||
@@ -1551,8 +1551,6 @@ export const ru: TranslationResources = {
|
|||||||
noFiles: "Файлы и каталоги не найдены",
|
noFiles: "Файлы и каталоги не найдены",
|
||||||
noCommands: "Команды не найдены",
|
noCommands: "Команды не найдены",
|
||||||
failedToLoad: "Не удалось загрузить",
|
failedToLoad: "Не удалось загрузить",
|
||||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
|
||||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "Не удалось загрузить старую историю.",
|
failed: "Не удалось загрузить старую историю.",
|
||||||
|
|||||||
@@ -1505,8 +1505,6 @@ export const zhCN: TranslationResources = {
|
|||||||
noFiles: "没有找到文件或目录",
|
noFiles: "没有找到文件或目录",
|
||||||
noCommands: "没有找到 commands",
|
noCommands: "没有找到 commands",
|
||||||
failedToLoad: "加载失败",
|
failedToLoad: "加载失败",
|
||||||
chooseProjectForCommands: "选择项目以查看命令",
|
|
||||||
chooseModelForCommands: "选择模型以查看命令",
|
|
||||||
},
|
},
|
||||||
loadOlderHistory: {
|
loadOlderHistory: {
|
||||||
failed: "无法加载更早历史",
|
failed: "无法加载更早历史",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||||
import { filterSelectableModels } from "./model-catalog";
|
import { filterSelectableModels } from "./model-catalog";
|
||||||
@@ -286,22 +286,19 @@ export function resolveEffectiveComposerThinkingOptionId(
|
|||||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDraftCommandTarget(input: {
|
export function buildDraftCommandConfig(input: {
|
||||||
selection: ProviderSelectionState;
|
selection: ProviderSelectionState;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
effectiveModelId: string;
|
effectiveModelId: string;
|
||||||
effectiveThinkingOptionId: string;
|
effectiveThinkingOptionId: string;
|
||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
}): DraftCommandTarget {
|
}): DraftCommandConfig | undefined {
|
||||||
const cwd = input.cwd.trim();
|
const cwd = input.cwd.trim();
|
||||||
if (!cwd) {
|
if (!input.selection.provider || !cwd) {
|
||||||
return { status: "needs-project" };
|
return undefined;
|
||||||
}
|
|
||||||
if (!input.selection.provider) {
|
|
||||||
return { status: "needs-provider" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
return {
|
||||||
provider: input.selection.provider,
|
provider: input.selection.provider,
|
||||||
cwd,
|
cwd,
|
||||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||||
@@ -313,7 +310,6 @@ export function buildDraftCommandTarget(input: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||||
};
|
};
|
||||||
return { status: "ready", config };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSubmissionReadiness(input: {
|
export function resolveSubmissionReadiness(input: {
|
||||||
|
|||||||
+4
-4
@@ -2,14 +2,14 @@ import { useMemo } from "react";
|
|||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||||
import type { UsageBalance, UsageTone } from "./types";
|
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
|
||||||
|
|
||||||
interface ResolvedBalance {
|
interface ResolvedBalance {
|
||||||
amountText: string;
|
amountText: string;
|
||||||
usedPct: number | null;
|
usedPct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBalance(balance: UsageBalance): ResolvedBalance {
|
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
|
||||||
const { used, remaining, limit, unit } = balance;
|
const { used, remaining, limit, unit } = balance;
|
||||||
if (limit != null && limit > 0) {
|
if (limit != null && limit > 0) {
|
||||||
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
||||||
@@ -26,7 +26,7 @@ function resolveBalance(balance: UsageBalance): ResolvedBalance {
|
|||||||
return { amountText: "—", usedPct: null };
|
return { amountText: "—", usedPct: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillToneStyle(tone: UsageTone) {
|
function fillToneStyle(tone: ProviderUsageTone) {
|
||||||
switch (tone) {
|
switch (tone) {
|
||||||
case "ok":
|
case "ok":
|
||||||
return styles.fillOk;
|
return styles.fillOk;
|
||||||
@@ -39,7 +39,7 @@ function fillToneStyle(tone: UsageTone) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
|
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
|
||||||
const { amountText, usedPct } = resolveBalance(balance);
|
const { amountText, usedPct } = resolveBalance(balance);
|
||||||
const tone = balance.tone ?? "default";
|
const tone = balance.tone ?? "default";
|
||||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||||
|
import { formatAgo } from "./format";
|
||||||
|
import type { ProviderUsage } from "./types";
|
||||||
|
import { ProviderUsageWindowBar } from "./window-bar";
|
||||||
|
|
||||||
|
interface ProviderUsageIconProps {
|
||||||
|
iconKey: string;
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||||
|
const Icon = getProviderIcon(iconKey);
|
||||||
|
return <Icon size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||||
|
|
||||||
|
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
function statusText(usage: ProviderUsage): string | null {
|
||||||
|
if (usage.status === "available") return null;
|
||||||
|
return usage.status === "error" ? "Error" : "Unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function footerText(usage: ProviderUsage): string | null {
|
||||||
|
const updated = formatAgo(usage.fetchedAt);
|
||||||
|
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||||
|
(part): part is string => typeof part === "string" && part.length > 0,
|
||||||
|
);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderUsageCard({
|
||||||
|
usage,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
usage: ProviderUsage;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const status = statusText(usage);
|
||||||
|
const footer = footerText(usage);
|
||||||
|
const balances = usage.balances ?? [];
|
||||||
|
const details = usage.details ?? [];
|
||||||
|
|
||||||
|
const containerStyle = useMemo(
|
||||||
|
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||||
|
[compact],
|
||||||
|
);
|
||||||
|
const dotStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.statusDot,
|
||||||
|
usage.status === "available" && styles.statusDotAvailable,
|
||||||
|
usage.status === "error" && styles.statusDotError,
|
||||||
|
],
|
||||||
|
[usage.status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{usage.displayName}
|
||||||
|
</Text>
|
||||||
|
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
{status ? (
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<View style={dotStyle} />
|
||||||
|
<Text style={styles.statusLabel}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{usage.error ? (
|
||||||
|
<Text style={styles.error} numberOfLines={3}>
|
||||||
|
{usage.error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{usage.windows.map((window) => (
|
||||||
|
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||||
|
))}
|
||||||
|
{balances.map((balance) => (
|
||||||
|
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{details.length > 0 ? (
|
||||||
|
<View style={styles.details}>
|
||||||
|
{details.map((detail) => (
|
||||||
|
<View key={detail.id} style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||||
|
{detail.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.detailValue} numberOfLines={1}>
|
||||||
|
{detail.value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<Text style={styles.footer} numberOfLines={1}>
|
||||||
|
{footer}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
containerPadded: {
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
containerCompact: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1.5],
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
statusDotAvailable: {
|
||||||
|
backgroundColor: theme.colors.statusSuccess,
|
||||||
|
},
|
||||||
|
statusDotError: {
|
||||||
|
backgroundColor: theme.colors.statusDanger,
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
bars: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
detailValue: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// User-facing copy for the provider-usage surfaces, centralized so localization
|
||||||
|
// is a single-file change. INTEGRATION: move these into the i18n resources
|
||||||
|
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
|
||||||
|
// call sites once the feature is wired to data. Kept inline for now because the
|
||||||
|
// surfaces are not yet mounted and the locale files are being edited elsewhere.
|
||||||
|
export const providerUsageCopy = {
|
||||||
|
title: "Plan usage",
|
||||||
|
refresh: "Refresh",
|
||||||
|
refreshing: "Refreshing...",
|
||||||
|
loading: "Loading usage...",
|
||||||
|
empty: "No usage data",
|
||||||
|
errorTitle: "Unable to load usage",
|
||||||
|
hostUnavailable: "Connect to this host to see provider usage",
|
||||||
|
hostUpgradeRequired: "Update the host to see provider usage",
|
||||||
|
clientUnavailable: "Host connection is not ready",
|
||||||
|
retry: "Try again",
|
||||||
|
tooltipLoading: "Loading plan usage…",
|
||||||
|
} as const;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||||
import type { UsageBalanceUnit } from "./types";
|
import type { ProviderUsageBalanceUnit } from "./types";
|
||||||
|
|
||||||
export function clampPct(value: number): number {
|
export function clampPct(value: number): number {
|
||||||
return Math.max(0, Math.min(100, value));
|
return Math.max(0, Math.min(100, value));
|
||||||
@@ -28,7 +28,20 @@ export function formatResetLabel(iso: string | null | undefined): string | null
|
|||||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(value: number, unit: UsageBalanceUnit): string {
|
export function formatAgo(iso: string | null | undefined): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
if (!Number.isFinite(diffMs)) return null;
|
||||||
|
if (diffMs < 60_000) return "just now";
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays > 0) return `${diffDays}d ago`;
|
||||||
|
if (diffHours > 0) return `${diffHours}h ago`;
|
||||||
|
return `${diffMinutes}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "usd":
|
case "usd":
|
||||||
return `$${value.toFixed(2)}`;
|
return `$${value.toFixed(2)}`;
|
||||||
@@ -2,22 +2,16 @@ import { Fragment } from "react";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { UsageCard } from "./card";
|
import { ProviderUsageCard } from "./card";
|
||||||
import type { UsageReportEntry } from "./types";
|
import type { ProviderUsage } from "./types";
|
||||||
|
|
||||||
export function UsageList({
|
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
|
||||||
serverId,
|
|
||||||
reports,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
reports: UsageReportEntry[];
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{reports.map((entry, index) => (
|
{providers.map((usage, index) => (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={usage.providerId}>
|
||||||
{index > 0 ? <View style={styles.divider} /> : null}
|
{index > 0 ? <View style={styles.divider} /> : null}
|
||||||
<UsageCard serverId={serverId} entry={entry} />
|
<ProviderUsageCard usage={usage} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { RefreshCw } from "lucide-react-native";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||||
|
import { providerUsageCopy } from "./copy";
|
||||||
|
import { ProviderUsageList } from "./list";
|
||||||
|
import type { ProviderUsageView } from "./types";
|
||||||
|
|
||||||
|
export function ProviderUsageSettingsSection({
|
||||||
|
view,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
view: ProviderUsageView;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||||
|
|
||||||
|
const refreshButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={RefreshCw}
|
||||||
|
loading={busy}
|
||||||
|
onPress={onRefresh}
|
||||||
|
accessibilityLabel={providerUsageCopy.refresh}
|
||||||
|
>
|
||||||
|
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[busy, onRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={providerUsageCopy.title}
|
||||||
|
testID="provider-usage-card"
|
||||||
|
trailing={refreshButton}
|
||||||
|
>
|
||||||
|
<ProviderUsageBody view={view} onRefresh={onRefresh} />
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderUsageBody({
|
||||||
|
view,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
view: ProviderUsageView;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
if (view.kind === "loading") {
|
||||||
|
return (
|
||||||
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
|
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.kind === "error") {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
size="sm"
|
||||||
|
variant="error"
|
||||||
|
title={providerUsageCopy.errorTitle}
|
||||||
|
description={view.message}
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||||
|
{providerUsageCopy.retry}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.payload.providers.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
|
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProviderUsageList providers={view.payload.providers} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
emptyCard: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { UsageTone } from "./types";
|
import type { ProviderUsageTone } from "./types";
|
||||||
|
|
||||||
export function deriveTone(usedPct: number | null | undefined): UsageTone {
|
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
|
||||||
if (usedPct == null) return "default";
|
if (usedPct == null) return "default";
|
||||||
if (usedPct > 90) return "danger";
|
if (usedPct > 90) return "danger";
|
||||||
if (usedPct >= 70) return "warning";
|
if (usedPct >= 70) return "warning";
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { ProviderUsageCard } from "./card";
|
||||||
|
import { providerUsageCopy } from "./copy";
|
||||||
|
import type { ProviderUsage, ProviderUsageView } from "./types";
|
||||||
|
|
||||||
|
function matchProvider(
|
||||||
|
providers: ProviderUsage[],
|
||||||
|
activeProviderId: string | null | undefined,
|
||||||
|
): ProviderUsage | null {
|
||||||
|
if (!activeProviderId) return null;
|
||||||
|
const target = activeProviderId.toLowerCase();
|
||||||
|
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders the active agent's provider usage inside the context-meter tooltip.
|
||||||
|
// Returns nothing when the active provider has no usage entry, so the meter's
|
||||||
|
// own context section stays the whole tooltip.
|
||||||
|
export function ProviderUsageTooltipSection({
|
||||||
|
view,
|
||||||
|
activeProviderId,
|
||||||
|
}: {
|
||||||
|
view: ProviderUsageView;
|
||||||
|
activeProviderId: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (view.kind === "loading") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.kind === "error") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<Text style={styles.error}>{view.message}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = matchProvider(view.payload.providers, activeProviderId);
|
||||||
|
if (!usage) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<ProviderUsageCard usage={usage} compact />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
// Same token the popover draws its own outline with, so the rule reads as the
|
||||||
|
// popover's edge. `border` is invisible here (equals the popover background).
|
||||||
|
backgroundColor: theme.colors.borderAccent,
|
||||||
|
marginVertical: theme.spacing[2],
|
||||||
|
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
|
||||||
|
marginHorizontal: -theme.spacing[2],
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type {
|
||||||
|
ProviderUsage,
|
||||||
|
ProviderUsageBalance,
|
||||||
|
ProviderUsageDetail,
|
||||||
|
ProviderUsageListResponseMessage,
|
||||||
|
ProviderUsageStatus,
|
||||||
|
ProviderUsageTone,
|
||||||
|
ProviderUsageWindow,
|
||||||
|
} from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ProviderUsage,
|
||||||
|
ProviderUsageBalance,
|
||||||
|
ProviderUsageDetail,
|
||||||
|
ProviderUsageStatus,
|
||||||
|
ProviderUsageTone,
|
||||||
|
ProviderUsageWindow,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
|
||||||
|
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||||
|
|
||||||
|
export type ProviderUsageView =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { providerUsageCopy } from "./copy";
|
||||||
|
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
|
||||||
|
|
||||||
|
export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
type ProviderUsageClient = Pick<DaemonClient, "listProviderUsage">;
|
||||||
|
|
||||||
|
export function providerUsageQueryKey(serverId: string | null | undefined) {
|
||||||
|
return ["providerUsage", serverId ?? ""] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
|
||||||
|
return client.listProviderUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProviderUsageOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProviderUsage(
|
||||||
|
serverId: string | null | undefined,
|
||||||
|
options: UseProviderUsageOptions = {},
|
||||||
|
): {
|
||||||
|
view: ProviderUsageView;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
canFetch: boolean;
|
||||||
|
} {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
|
const supportsProviderUsage = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
|
||||||
|
);
|
||||||
|
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
|
||||||
|
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
|
||||||
|
const enabled = Boolean((options.enabled ?? true) && canFetch);
|
||||||
|
|
||||||
|
const queryFn = useCallback(async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(providerUsageCopy.clientUnavailable);
|
||||||
|
}
|
||||||
|
return fetchProviderUsage(client);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled,
|
||||||
|
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!canFetch) return;
|
||||||
|
await queryClient.invalidateQueries({ queryKey });
|
||||||
|
await queryClient.fetchQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||||
|
});
|
||||||
|
}, [canFetch, queryClient, queryFn, queryKey]);
|
||||||
|
|
||||||
|
const view = useMemo<ProviderUsageView>(() => {
|
||||||
|
if (!serverId || !client || !isConnected) {
|
||||||
|
return { kind: "error", message: providerUsageCopy.hostUnavailable };
|
||||||
|
}
|
||||||
|
if (!supportsProviderUsage) {
|
||||||
|
return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
|
||||||
|
}
|
||||||
|
if (query.data) {
|
||||||
|
return {
|
||||||
|
kind: "ready",
|
||||||
|
payload: query.data,
|
||||||
|
isRefreshing: query.isFetching,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.isError) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
message: query.error instanceof Error ? query.error.message : String(query.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "loading" };
|
||||||
|
}, [
|
||||||
|
client,
|
||||||
|
isConnected,
|
||||||
|
query.data,
|
||||||
|
query.error,
|
||||||
|
query.isError,
|
||||||
|
query.isFetching,
|
||||||
|
serverId,
|
||||||
|
supportsProviderUsage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { view, refresh, canFetch };
|
||||||
|
}
|
||||||
+10
-5
@@ -2,11 +2,16 @@ import { useMemo } from "react";
|
|||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { clampPct, formatPct, formatResetLabel } from "./format";
|
import { clampPct, formatPct, formatResetLabel } from "./format";
|
||||||
import { usedPercent } from "./model";
|
|
||||||
import { deriveTone } from "./tone";
|
import { deriveTone } from "./tone";
|
||||||
import type { UsageTone, UsageWindow } from "./types";
|
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
|
||||||
|
|
||||||
function fillToneStyle(tone: UsageTone) {
|
function resolveUsedPct(window: ProviderUsageWindow): number | null {
|
||||||
|
if (window.usedPct != null) return window.usedPct;
|
||||||
|
if (window.remainingPct != null) return 100 - window.remainingPct;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillToneStyle(tone: ProviderUsageTone) {
|
||||||
switch (tone) {
|
switch (tone) {
|
||||||
case "ok":
|
case "ok":
|
||||||
return styles.fillOk;
|
return styles.fillOk;
|
||||||
@@ -19,8 +24,8 @@ function fillToneStyle(tone: UsageTone) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageWindowBar({ window }: { window: UsageWindow }) {
|
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
|
||||||
const usedPct = usedPercent(window);
|
const usedPct = resolveUsedPct(window);
|
||||||
const tone = window.tone ?? deriveTone(usedPct);
|
const tone = window.tone ?? deriveTone(usedPct);
|
||||||
|
|
||||||
const fillWidth = clampPct(usedPct ?? 0);
|
const fillWidth = clampPct(usedPct ?? 0);
|
||||||
@@ -158,28 +158,17 @@ class FakeDaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ownedSubscriptions = true;
|
public ownedSubscriptions = true;
|
||||||
private daemonVersion: string | null = "0.8.0";
|
|
||||||
|
|
||||||
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
|
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
|
||||||
if (this.daemonVersion === null) return null;
|
|
||||||
return {
|
return {
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
hostname: "test",
|
hostname: "test",
|
||||||
version: this.daemonVersion,
|
version: "0.8.0",
|
||||||
features: { ownedSubscriptions: this.ownedSubscriptions },
|
features: { ownedSubscriptions: this.ownedSubscriptions },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Like the real client, the server info is cleared while disconnected and replaced by the
|
|
||||||
// restarted daemon's handshake before the client reports connected again.
|
|
||||||
daemonRestartsAs(version: string): void {
|
|
||||||
this.daemonVersion = null;
|
|
||||||
this.setConnectionState({ status: "disconnected", reason: "Connection lost" });
|
|
||||||
this.daemonVersion = version;
|
|
||||||
this.setConnectionState({ status: "connected" });
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
|
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
listener(this.state);
|
listener(this.state);
|
||||||
@@ -2140,37 +2129,6 @@ describe("HostRuntimeStore", () => {
|
|||||||
expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined();
|
expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the restarted daemon's version after the same client reconnects", async () => {
|
|
||||||
const host = makeHost({
|
|
||||||
serverId: "srv_restarted",
|
|
||||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
|
||||||
});
|
|
||||||
const fakeClient = new FakeDaemonClient();
|
|
||||||
fakeClient.setConnectionState({ status: "connected" });
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
deps: {
|
|
||||||
createClient: () => fakeClient as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async ({ host: hostProfile }) => ({
|
|
||||||
client: fakeClient as unknown as DaemonClient,
|
|
||||||
serverId: hostProfile.serverId,
|
|
||||||
hostname: hostProfile.label ?? null,
|
|
||||||
}),
|
|
||||||
getClientId: async () => "cid_test_runtime",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
store.syncHosts([host]);
|
|
||||||
await waitForHostOnline(store, host.serverId);
|
|
||||||
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0");
|
|
||||||
|
|
||||||
fakeClient.daemonRestartsAs("0.9.1");
|
|
||||||
|
|
||||||
expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient);
|
|
||||||
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1");
|
|
||||||
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drains snapshot and buffered running transitions exactly once", async () => {
|
it("drains snapshot and buffered running transitions exactly once", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
serverId: "srv_legacy_transitions",
|
serverId: "srv_legacy_transitions",
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
|||||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
toDaemonServerInfo,
|
|
||||||
type Agent,
|
type Agent,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
type ProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
@@ -2267,13 +2266,6 @@ export class HostRuntimeStore {
|
|||||||
const sessionStore = useSessionStore.getState();
|
const sessionStore = useSessionStore.getState();
|
||||||
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
||||||
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
||||||
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
|
|
||||||
// change its version and features) only reaches the store here. The client clears it while
|
|
||||||
// disconnected; keep the last known value until the next handshake.
|
|
||||||
const serverInfo = snapshot.client.getLastServerInfoMessage();
|
|
||||||
if (serverInfo) {
|
|
||||||
sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearHostReplica(serverId: string): void {
|
private clearHostReplica(serverId: string): void {
|
||||||
@@ -2661,22 +2653,21 @@ export function useHostRuntimeConnectionStatuses(
|
|||||||
serverIds: readonly string[],
|
serverIds: readonly string[],
|
||||||
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
// The snapshot is the statuses themselves, joined into a string so React compares by
|
const version = useSyncExternalStore(
|
||||||
// value. A version counter read only for reactivity is dropped by the React Compiler.
|
|
||||||
const readStatuses = () =>
|
|
||||||
serverIds
|
|
||||||
.map((serverId) => store.getSnapshot(serverId)?.connectionStatus ?? "connecting")
|
|
||||||
.join("\n");
|
|
||||||
const statuses = useSyncExternalStore(
|
|
||||||
(onStoreChange) => store.subscribeAll(onStoreChange),
|
(onStoreChange) => store.subscribeAll(onStoreChange),
|
||||||
readStatuses,
|
() => store.getVersion(),
|
||||||
readStatuses,
|
() => store.getVersion(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const values = statuses.split("\n") as HostRuntimeConnectionStatus[];
|
// The aggregate version is the reactivity trigger; re-read snapshots on every host tick.
|
||||||
return new Map(serverIds.map((serverId, index) => [serverId, values[index]]));
|
void version;
|
||||||
}, [serverIds, statuses]);
|
const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [
|
||||||
|
serverId,
|
||||||
|
store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
||||||
|
]);
|
||||||
|
return new Map(entries);
|
||||||
|
}, [serverIds, store, version]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeLastError(serverId: string): string | null {
|
export function useHostRuntimeLastError(serverId: string): string | null {
|
||||||
|
|||||||
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
|
|||||||
clearDraft={handleClearDraft}
|
clearDraft={handleClearDraft}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoFocusKey={launchFocusKey}
|
autoFocusKey={launchFocusKey}
|
||||||
commandDraft={composerState?.commandDraft}
|
commandDraftConfig={composerState?.commandDraftConfig}
|
||||||
agentControls={agentControlsWithDisabled}
|
agentControls={agentControlsWithDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|||||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { PageLayout } from "@/components/page-layout";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||||
import { AddHostModal } from "@/components/add-host-modal";
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
|
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
|
||||||
@@ -1305,25 +1305,30 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const installedPlugins = useInstalledPlugins();
|
const installedPlugins = useInstalledPlugins();
|
||||||
const detailTitle = ((): string | undefined => {
|
const detailHeader = ((): {
|
||||||
|
title: string;
|
||||||
|
titleAccessory?: ReactNode;
|
||||||
|
} | null => {
|
||||||
if (view.kind === "plugin") {
|
if (view.kind === "plugin") {
|
||||||
const screen = installedPlugins
|
const screen = installedPlugins
|
||||||
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
||||||
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||||
return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`;
|
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` };
|
||||||
}
|
}
|
||||||
if (view.kind === "host") {
|
if (view.kind === "host") {
|
||||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||||
return item ? t(item.labelKey) : undefined;
|
if (!item) return null;
|
||||||
|
return { title: t(item.labelKey) };
|
||||||
}
|
}
|
||||||
if (view.kind === "section") {
|
if (view.kind === "section") {
|
||||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||||
return item ? t(item.labelKey) : undefined;
|
if (!item) return null;
|
||||||
|
return { title: t(item.labelKey) };
|
||||||
}
|
}
|
||||||
if (view.kind === "project") {
|
if (view.kind === "project") {
|
||||||
return t("settings.projects");
|
return { title: t("settings.projects") };
|
||||||
}
|
}
|
||||||
return undefined;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const content: ReactNode = (() => {
|
const content: ReactNode = (() => {
|
||||||
@@ -1394,6 +1399,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const desktopPageTitle = detailHeader ? (
|
||||||
|
<View style={styles.pageTitleRow}>
|
||||||
|
<Text style={styles.pageTitle} testID="settings-detail-header-title">
|
||||||
|
{detailHeader.title}
|
||||||
|
</Text>
|
||||||
|
{detailHeader.titleAccessory}
|
||||||
|
</View>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const addHostModals = (
|
const addHostModals = (
|
||||||
<>
|
<>
|
||||||
<AddHostMethodModal
|
<AddHostMethodModal
|
||||||
@@ -1450,9 +1464,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
<BackHeader
|
||||||
{content}
|
title={detailHeader?.title}
|
||||||
</PageLayout>
|
titleAccessory={detailHeader?.titleAccessory}
|
||||||
|
onBack={handleBackFromDetail}
|
||||||
|
/>
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||||
|
<View style={styles.content}>{content}</View>
|
||||||
|
</ScrollView>
|
||||||
{addHostModals}
|
{addHostModals}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1478,9 +1497,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
<WindowChromeRegion corners="top-right">
|
<WindowChromeRegion corners="top-right">
|
||||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||||
<PageLayout title={detailTitle} titleTestID="settings-detail-header-title">
|
{/* Keeps the titlebar drag region and window controls; the page
|
||||||
{content}
|
title lives in the content, like a document heading. */}
|
||||||
</PageLayout>
|
<ScreenHeader borderless />
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{desktopPageTitle}
|
||||||
|
{content}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
</View>
|
</View>
|
||||||
@@ -1511,6 +1536,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
content: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
paddingTop: theme.spacing[6],
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 720,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
pageTitleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
// Line up with the section titles, which sit inset from their cards.
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: theme.fontSize["4xl"],
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
aboutValue: {
|
aboutValue: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import {
|
|||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||||
import { HostUsageSection } from "@/usage";
|
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
||||||
|
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||||
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
|
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
|
||||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -342,6 +343,10 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
export function HostUsagePage({ serverId }: { serverId: string }) {
|
export function HostUsagePage({ serverId }: { serverId: string }) {
|
||||||
const host = useHostProfile(serverId);
|
const host = useHostProfile(serverId);
|
||||||
|
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void refreshProviderUsage();
|
||||||
|
}, [refreshProviderUsage]);
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return <HostNotFound />;
|
return <HostNotFound />;
|
||||||
@@ -349,7 +354,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<HostUsageSection serverId={serverId} />
|
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ import type { Theme } from "@/styles/theme";
|
|||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
||||||
@@ -149,6 +148,12 @@ function formatAgentTooltipTitle(singleLineTitle: string): string {
|
|||||||
return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`;
|
return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAgentTooltipActivity(compactActivity: string): string {
|
||||||
|
if (compactActivity === "now") return "just now";
|
||||||
|
if (/^\d/.test(compactActivity)) return `${compactActivity} ago`;
|
||||||
|
return compactActivity;
|
||||||
|
}
|
||||||
|
|
||||||
function AgentTabTooltipBody({
|
function AgentTabTooltipBody({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -164,7 +169,7 @@ function AgentTabTooltipBody({
|
|||||||
return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null;
|
return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null;
|
||||||
});
|
});
|
||||||
const compactActivity = useCompactTimeAgo(lastActivityAt);
|
const compactActivity = useCompactTimeAgo(lastActivityAt);
|
||||||
const activity = formatCompactTimeAgoAsProse(compactActivity);
|
const activity = formatAgentTooltipActivity(compactActivity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.tooltipAgentContent}>
|
<View style={styles.tooltipAgentContent}>
|
||||||
|
|||||||
@@ -285,19 +285,6 @@ export interface DaemonServerInfo {
|
|||||||
features?: ServerInfoStatusPayload["features"];
|
features?: ServerInfoStatusPayload["features"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo {
|
|
||||||
return {
|
|
||||||
serverId: serverInfo.serverId,
|
|
||||||
hostname: serverInfo.hostname ?? null,
|
|
||||||
version: serverInfo.version ?? null,
|
|
||||||
...(serverInfo.desktopManaged !== undefined
|
|
||||||
? { desktopManaged: serverInfo.desktopManaged }
|
|
||||||
: {}),
|
|
||||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
|
||||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentTimelineCursorState {
|
export interface AgentTimelineCursorState {
|
||||||
epoch: string;
|
epoch: string;
|
||||||
startSeq: number;
|
startSeq: number;
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { RefreshCw } from "lucide-react-native";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Text, View, type StyleProp, type TextStyle } from "react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import {
|
|
||||||
iconButtonChromeGlyphSize,
|
|
||||||
mutedIconColorMapping,
|
|
||||||
smallIconButtonChromeFrameSize,
|
|
||||||
} from "@/components/ui/icon-button-chrome";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
||||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
|
||||||
import { UsageBalanceBar } from "./balance-bar";
|
|
||||||
import { usageCopy } from "./copy";
|
|
||||||
import { formatUsageFreshness, type UsageRefresh } from "./model";
|
|
||||||
import { useReportRefresh } from "./queries";
|
|
||||||
import { UsageSourceIcon } from "./source-icon";
|
|
||||||
import type { UsageReport, UsageReportEntry } from "./types";
|
|
||||||
import { UsageWindowBar } from "./window-bar";
|
|
||||||
|
|
||||||
function statusText(report: UsageReport): string | null {
|
|
||||||
if (report.status === "available") return null;
|
|
||||||
return report.status === "error" ? "Error" : "Unavailable";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedRefreshIcon = withUnistyles(RefreshCw);
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
||||||
|
|
||||||
export function UsageCard({
|
|
||||||
serverId,
|
|
||||||
entry,
|
|
||||||
compact = false,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
entry: UsageReportEntry;
|
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
|
|
||||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
|
||||||
const showsFreshnessInline = isNative || isCompact;
|
|
||||||
const usage = entry.report;
|
|
||||||
const status = statusText(usage);
|
|
||||||
const footer = entry.account.label ?? null;
|
|
||||||
const balances = usage.balances ?? [];
|
|
||||||
const details = usage.details ?? [];
|
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
|
||||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
|
||||||
[compact],
|
|
||||||
);
|
|
||||||
const dotStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.statusDot,
|
|
||||||
usage.status === "available" && styles.statusDotAvailable,
|
|
||||||
usage.status === "error" && styles.statusDotError,
|
|
||||||
],
|
|
||||||
[usage.status],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={containerStyle}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
|
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
|
||||||
{entry.sourceLabel}
|
|
||||||
</Text>
|
|
||||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
|
||||||
<View style={styles.headerSpacer} />
|
|
||||||
{status ? (
|
|
||||||
<View style={styles.statusRow}>
|
|
||||||
<View style={dotStyle} />
|
|
||||||
<Text style={styles.statusLabel}>{status}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<UsageRefreshButton
|
|
||||||
sourceLabel={entry.sourceLabel}
|
|
||||||
fetchedAt={entry.fetchedAt}
|
|
||||||
refreshState={refreshState}
|
|
||||||
onRefresh={refresh}
|
|
||||||
compact={isCompact}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{usage.error ? (
|
|
||||||
<Text style={styles.error} numberOfLines={3}>
|
|
||||||
{usage.error}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
|
||||||
<View style={styles.bars}>
|
|
||||||
{usage.windows.map((window) => (
|
|
||||||
<UsageWindowBar key={window.id} window={window} />
|
|
||||||
))}
|
|
||||||
{balances.map((balance) => (
|
|
||||||
<UsageBalanceBar key={balance.id} balance={balance} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{details.length > 0 ? (
|
|
||||||
<View style={styles.details}>
|
|
||||||
{details.map((detail) => (
|
|
||||||
<View key={detail.id} style={styles.detailRow}>
|
|
||||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
|
||||||
{detail.label}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.detailValue} numberOfLines={1}>
|
|
||||||
{detail.value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{footer || showsFreshnessInline ? (
|
|
||||||
<View style={styles.footerRow}>
|
|
||||||
<Text style={styles.footer} numberOfLines={1}>
|
|
||||||
{footer}
|
|
||||||
</Text>
|
|
||||||
{showsFreshnessInline ? (
|
|
||||||
<UsageFreshness
|
|
||||||
fetchedAt={entry.fetchedAt}
|
|
||||||
style={styles.freshness}
|
|
||||||
testID="usage-freshness"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{refreshState === "failed" ? (
|
|
||||||
<Text style={styles.error} testID="usage-refresh-error">
|
|
||||||
{usageCopy.refreshFailed}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
|
|
||||||
function UsageRefreshButton({
|
|
||||||
sourceLabel,
|
|
||||||
fetchedAt,
|
|
||||||
refreshState,
|
|
||||||
onRefresh,
|
|
||||||
compact,
|
|
||||||
}: {
|
|
||||||
sourceLabel: string;
|
|
||||||
fetchedAt: string;
|
|
||||||
refreshState: UsageRefresh;
|
|
||||||
onRefresh: () => void;
|
|
||||||
compact: boolean;
|
|
||||||
}) {
|
|
||||||
const isPending = refreshState === "pending";
|
|
||||||
const iconSize = iconButtonChromeGlyphSize("small", compact);
|
|
||||||
const freshness = useMemo(
|
|
||||||
() => (
|
|
||||||
<UsageFreshness
|
|
||||||
fetchedAt={fetchedAt}
|
|
||||||
style={styles.tooltipText}
|
|
||||||
testID="usage-freshness-tooltip"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[fetchedAt],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<ToolbarButton
|
|
||||||
label={`${usageCopy.refresh} ${sourceLabel}`}
|
|
||||||
tooltip={freshness}
|
|
||||||
tooltipSide="top"
|
|
||||||
compact={compact}
|
|
||||||
disabled={isPending}
|
|
||||||
onPress={onRefresh}
|
|
||||||
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
|
|
||||||
testID="usage-refresh"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
|
|
||||||
) : (
|
|
||||||
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
|
|
||||||
)}
|
|
||||||
</ToolbarButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
|
|
||||||
function UsageFreshness({
|
|
||||||
fetchedAt,
|
|
||||||
style,
|
|
||||||
testID,
|
|
||||||
}: {
|
|
||||||
fetchedAt: string;
|
|
||||||
style: StyleProp<TextStyle>;
|
|
||||||
testID: string;
|
|
||||||
}) {
|
|
||||||
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
|
|
||||||
return (
|
|
||||||
<Text style={style} numberOfLines={1} testID={testID}>
|
|
||||||
{formatUsageFreshness(elapsed)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
|
|
||||||
// the button's larger hitbox overhangs both instead of pushing them.
|
|
||||||
function iconHitboxOverhang(compact: boolean) {
|
|
||||||
const overhang =
|
|
||||||
(smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
|
|
||||||
return { marginRight: -overhang, marginVertical: -overhang };
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
containerPadded: {
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[4],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
},
|
|
||||||
containerCompact: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
},
|
|
||||||
headerSpacer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
statusRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1.5],
|
|
||||||
},
|
|
||||||
statusDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
statusDotAvailable: {
|
|
||||||
backgroundColor: theme.colors.statusSuccess,
|
|
||||||
},
|
|
||||||
statusDotError: {
|
|
||||||
backgroundColor: theme.colors.statusDanger,
|
|
||||||
},
|
|
||||||
statusLabel: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
bars: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
details: {
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
detailRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
detailLabel: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
detailValue: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
color: theme.colors.palette.red[300],
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: theme.fontSize.sm * 1.4,
|
|
||||||
},
|
|
||||||
footerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flex: 1,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
freshness: {
|
|
||||||
flexShrink: 0,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
tooltipText: {
|
|
||||||
color: theme.colors.popoverForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
refreshButton: iconHitboxOverhang(false),
|
|
||||||
refreshButtonCompact: iconHitboxOverhang(true),
|
|
||||||
}));
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Text, View } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import type { MenuTriggerState } from "@/components/ui/menu";
|
|
||||||
import {
|
|
||||||
isToolbarLabelTriggerHighlighted,
|
|
||||||
toolbarLabelTriggerStyle,
|
|
||||||
toolbarLabelTriggerTextStyle,
|
|
||||||
} from "@/components/ui/toolbar-label-trigger";
|
|
||||||
import { UsageCard } from "./card";
|
|
||||||
import { usageCopy } from "./copy";
|
|
||||||
import type { UsagePill } from "./model";
|
|
||||||
import { useAgentUsage } from "./queries";
|
|
||||||
import { UsageSourceIcon } from "./source-icon";
|
|
||||||
|
|
||||||
function pillTriggerStyle(state: MenuTriggerState) {
|
|
||||||
return [toolbarLabelTriggerStyle(state), styles.trigger];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The agent's plan usage beside the composer controls. Opens the cached usage card;
|
|
||||||
* only the card's Refresh asks the source for a new report.
|
|
||||||
*/
|
|
||||||
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
|
|
||||||
const { pill, entry } = useAgentUsage(serverId, agentId);
|
|
||||||
|
|
||||||
if (!pill || !entry) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
style={pillTriggerStyle}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${pill.sourceLabel} ${usageCopy.planUsage}`}
|
|
||||||
testID="usage-composer-pill"
|
|
||||||
>
|
|
||||||
{(state) => (
|
|
||||||
<UsagePillContent pill={pill} highlighted={isToolbarLabelTriggerHighlighted(state)} />
|
|
||||||
)}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
offset={8}
|
|
||||||
width={300}
|
|
||||||
testID="usage-composer-popover"
|
|
||||||
sheetTitle={usageCopy.planUsage}
|
|
||||||
>
|
|
||||||
<View style={styles.popover}>
|
|
||||||
<UsageCard serverId={serverId} entry={entry} compact />
|
|
||||||
</View>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<UsageSourceIcon svg={pill.icon} size={14} />
|
|
||||||
{pill.text ? (
|
|
||||||
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
|
|
||||||
{pill.text}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
trigger: {
|
|
||||||
height: 28,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// User-facing copy for the usage surfaces, kept in one file so localization is a
|
|
||||||
// single-file change.
|
|
||||||
export const usageCopy = {
|
|
||||||
title: "Usage",
|
|
||||||
planUsage: "Plan usage",
|
|
||||||
refresh: "Refresh",
|
|
||||||
refreshing: "Refreshing...",
|
|
||||||
refreshFailed: "Unable to refresh usage",
|
|
||||||
updated: "Updated",
|
|
||||||
loading: "Loading usage...",
|
|
||||||
empty: "No usage data",
|
|
||||||
noHosts: "No connected hosts",
|
|
||||||
errorTitle: "Unable to load usage",
|
|
||||||
hostUnavailable: "Connect to this host to see usage",
|
|
||||||
hostUpgradeRequired: "Update the host to see usage",
|
|
||||||
clientUnavailable: "Host connection is not ready",
|
|
||||||
retry: "Try again",
|
|
||||||
} as const;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { usageCopy } from "./copy";
|
|
||||||
import { useHostUsage } from "./queries";
|
|
||||||
import { UsageSection } from "./usage-section";
|
|
||||||
|
|
||||||
/** A host's usage reports, for its settings page. */
|
|
||||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
|
||||||
const { view, refresh } = useHostUsage(serverId);
|
|
||||||
return (
|
|
||||||
<UsageSection
|
|
||||||
serverId={serverId}
|
|
||||||
title={usageCopy.planUsage}
|
|
||||||
view={view}
|
|
||||||
onRefresh={refresh}
|
|
||||||
testID="usage-card"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { UsageComposerPill } from "./composer-pill";
|
|
||||||
export { HostUsageSection } from "./host-usage-section";
|
|
||||||
export { UsageScreen } from "./usage-screen";
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
formatUsageFreshness,
|
|
||||||
groupUsageByHost,
|
|
||||||
replaceReport,
|
|
||||||
headlineWindow,
|
|
||||||
resolveUsagePill,
|
|
||||||
resolveUsageRefresh,
|
|
||||||
resolveUsageView,
|
|
||||||
type UsageQueryState,
|
|
||||||
} from "./model";
|
|
||||||
import type { UsageReportEntry, UsageWindow } from "./types";
|
|
||||||
|
|
||||||
function entry(input: {
|
|
||||||
windows?: UsageWindow[];
|
|
||||||
planLabel?: string;
|
|
||||||
icon?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
}): UsageReportEntry {
|
|
||||||
return {
|
|
||||||
id: `${input.sourceId ?? "fixture"}:account-1`,
|
|
||||||
account: {},
|
|
||||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
sourceId: input.sourceId ?? "fixture",
|
|
||||||
sourceLabel: "Fixture source",
|
|
||||||
...(input.icon ? { icon: input.icon } : {}),
|
|
||||||
report: {
|
|
||||||
status: "available",
|
|
||||||
...(input.planLabel ? { planLabel: input.planLabel } : {}),
|
|
||||||
windows: input.windows ?? [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ready(data: UsageReportEntry[]): UsageQueryState {
|
|
||||||
return { data, error: null, isFetching: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("resolveUsagePill", () => {
|
|
||||||
it("shows the source icon and the headline window percent", () => {
|
|
||||||
const pill = resolveUsagePill({
|
|
||||||
supportsUsage: true,
|
|
||||||
entry: entry({
|
|
||||||
icon: "<svg/>",
|
|
||||||
windows: [
|
|
||||||
{ id: "session", label: "Session", usedPct: 12 },
|
|
||||||
{ id: "weekly", label: "Weekly", usedPct: 64.6, headline: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pill).toEqual({ icon: "<svg/>", sourceLabel: "Fixture source", text: "65%" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the headline percent from remaining percent", () => {
|
|
||||||
const pill = resolveUsagePill({
|
|
||||||
supportsUsage: true,
|
|
||||||
entry: entry({
|
|
||||||
windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pill?.text).toBe("70%");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the plan label when no window is the headline", () => {
|
|
||||||
const pill = resolveUsagePill({
|
|
||||||
supportsUsage: true,
|
|
||||||
entry: entry({
|
|
||||||
planLabel: "Pro",
|
|
||||||
windows: [{ id: "session", label: "Session", usedPct: 40 }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows only the icon with neither a headline window nor a plan label", () => {
|
|
||||||
const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) });
|
|
||||||
|
|
||||||
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is hidden when the agent has no usage report", () => {
|
|
||||||
expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is hidden on a host without usage sources, whatever data is cached", () => {
|
|
||||||
const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] });
|
|
||||||
|
|
||||||
expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("headlineWindow", () => {
|
|
||||||
it("does not fall back to the first window", () => {
|
|
||||||
expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveUsageView", () => {
|
|
||||||
it("asks for a host update when the host lacks usage sources", () => {
|
|
||||||
expect(
|
|
||||||
resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }),
|
|
||||||
).toEqual({ kind: "unavailable", message: "Update the host to see usage" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks for a connection before anything else", () => {
|
|
||||||
expect(
|
|
||||||
resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }),
|
|
||||||
).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves from loading to ready to error", () => {
|
|
||||||
const base = { isConnected: true, supportsUsage: true };
|
|
||||||
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
|
|
||||||
expect(
|
|
||||||
resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }),
|
|
||||||
).toEqual({ kind: "ready", reports: [], isRefreshing: true });
|
|
||||||
expect(
|
|
||||||
resolveUsageView({
|
|
||||||
...base,
|
|
||||||
query: { data: undefined, error: new Error("boom"), isFetching: false },
|
|
||||||
}),
|
|
||||||
).toEqual({ kind: "error", message: "boom" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("groupUsageByHost", () => {
|
|
||||||
it("groups reports under each connected host in host order", () => {
|
|
||||||
const first = entry({ sourceId: "one" });
|
|
||||||
const second = entry({ sourceId: "two" });
|
|
||||||
const groups = groupUsageByHost(
|
|
||||||
[
|
|
||||||
{ serverId: "b", label: "Beta", isConnected: true, supportsUsage: true },
|
|
||||||
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
|
|
||||||
{ serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true },
|
|
||||||
],
|
|
||||||
new Map([
|
|
||||||
["a", ready([first])],
|
|
||||||
["b", ready([second, first])],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(groups).toEqual([
|
|
||||||
{
|
|
||||||
serverId: "b",
|
|
||||||
label: "Beta",
|
|
||||||
view: { kind: "ready", reports: [second, first], isRefreshing: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: "a",
|
|
||||||
label: "Alpha",
|
|
||||||
view: { kind: "ready", reports: [first], isRefreshing: false },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the update message in an old host's group", () => {
|
|
||||||
const groups = groupUsageByHost(
|
|
||||||
[
|
|
||||||
{ serverId: "new", label: "New", isConnected: true, supportsUsage: true },
|
|
||||||
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
|
|
||||||
],
|
|
||||||
new Map([["new", ready([])]]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(groups.map((group) => [group.label, group.view])).toEqual([
|
|
||||||
["New", { kind: "ready", reports: [], isRefreshing: false }],
|
|
||||||
["Old", { kind: "unavailable", message: "Update the host to see usage" }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatUsageFreshness", () => {
|
|
||||||
it("says when the report was fetched", () => {
|
|
||||||
expect(formatUsageFreshness("now")).toBe("Updated just now");
|
|
||||||
expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
|
|
||||||
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveUsageRefresh", () => {
|
|
||||||
it("is pending while a refresh runs, even after an earlier failure", () => {
|
|
||||||
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is failed after a refresh errors", () => {
|
|
||||||
expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idle before and after a successful refresh", () => {
|
|
||||||
expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("replaceReport", () => {
|
|
||||||
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
|
|
||||||
const beta = entry({ sourceId: "beta" });
|
|
||||||
|
|
||||||
it("swaps only the refreshed report, in place", () => {
|
|
||||||
const refreshed = entry({ sourceId: "alpha", planLabel: "New" });
|
|
||||||
expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a report the daemon no longer knows", () => {
|
|
||||||
expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
|
||||||
import { usageCopy } from "./copy";
|
|
||||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
|
||||||
|
|
||||||
export function usedPercent(window: UsageWindow): number | null {
|
|
||||||
if (window.usedPct != null) return window.usedPct;
|
|
||||||
if (window.remainingPct != null) return 100 - window.remainingPct;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The window the source marked as its headline. Sources own that choice; there is no fallback. */
|
|
||||||
export function headlineWindow(report: UsageReport): UsageWindow | null {
|
|
||||||
return report.windows.find((window) => window.headline === true) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsagePill {
|
|
||||||
icon: string | null;
|
|
||||||
sourceLabel: string;
|
|
||||||
/** Headline percent, else the plan label, else nothing beside the icon. */
|
|
||||||
text: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUsagePill(input: {
|
|
||||||
supportsUsage: boolean;
|
|
||||||
entry: UsageReportEntry | null | undefined;
|
|
||||||
}): UsagePill | null {
|
|
||||||
const { supportsUsage, entry } = input;
|
|
||||||
if (!supportsUsage || !entry) return null;
|
|
||||||
const window = headlineWindow(entry.report);
|
|
||||||
const percent = window ? usedPercent(window) : null;
|
|
||||||
return {
|
|
||||||
icon: entry.icon ?? null,
|
|
||||||
sourceLabel: entry.sourceLabel,
|
|
||||||
text:
|
|
||||||
percent != null
|
|
||||||
? `${Math.round(Math.max(0, Math.min(100, percent)))}%`
|
|
||||||
: (entry.report.planLabel ?? null),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
|
|
||||||
export function formatUsageFreshness(compactTimeAgo: string): string {
|
|
||||||
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
|
|
||||||
export type UsageRefresh = "idle" | "pending" | "failed";
|
|
||||||
|
|
||||||
export function resolveUsageRefresh(mutation: {
|
|
||||||
isPending: boolean;
|
|
||||||
error: unknown;
|
|
||||||
}): UsageRefresh {
|
|
||||||
if (mutation.isPending) return "pending";
|
|
||||||
if (mutation.error) return "failed";
|
|
||||||
return "idle";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A host's report list with one report swapped for its refreshed copy, in place.
|
|
||||||
* `null` means the daemon no longer knows the ID, so the report leaves the list.
|
|
||||||
*/
|
|
||||||
export function replaceReport(
|
|
||||||
reports: readonly UsageReportEntry[],
|
|
||||||
reportId: string,
|
|
||||||
refreshed: UsageReportEntry | null,
|
|
||||||
): UsageReportEntry[] {
|
|
||||||
if (!refreshed) return reports.filter((report) => report.id !== reportId);
|
|
||||||
return reports.map((report) => (report.id === reportId ? refreshed : report));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageQueryState {
|
|
||||||
data: UsageReportEntry[] | undefined;
|
|
||||||
error: unknown;
|
|
||||||
isFetching: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUsageView(input: {
|
|
||||||
isConnected: boolean;
|
|
||||||
supportsUsage: boolean;
|
|
||||||
query: UsageQueryState | undefined;
|
|
||||||
}): UsageView {
|
|
||||||
const { isConnected, supportsUsage, query } = input;
|
|
||||||
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable };
|
|
||||||
if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired };
|
|
||||||
if (query?.data) {
|
|
||||||
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
|
||||||
}
|
|
||||||
if (query?.error) {
|
|
||||||
return {
|
|
||||||
kind: "error",
|
|
||||||
message: query.error instanceof Error ? query.error.message : String(query.error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { kind: "loading" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageHost {
|
|
||||||
serverId: string;
|
|
||||||
label: string;
|
|
||||||
isConnected: boolean;
|
|
||||||
supportsUsage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageHostGroup {
|
|
||||||
serverId: string;
|
|
||||||
label: string;
|
|
||||||
view: UsageView;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One group per connected host, in host order. */
|
|
||||||
export function groupUsageByHost(
|
|
||||||
hosts: readonly UsageHost[],
|
|
||||||
queries: ReadonlyMap<string, UsageQueryState>,
|
|
||||||
): UsageHostGroup[] {
|
|
||||||
return hosts
|
|
||||||
.filter((host) => host.isConnected)
|
|
||||||
.map((host) => ({
|
|
||||||
serverId: host.serverId,
|
|
||||||
label: host.label,
|
|
||||||
view: resolveUsageView({
|
|
||||||
isConnected: true,
|
|
||||||
supportsUsage: host.supportsUsage,
|
|
||||||
query: queries.get(host.serverId),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { useFetchQueries, useFetchQuery } from "@/data/query";
|
|
||||||
import {
|
|
||||||
getHostRuntimeStore,
|
|
||||||
useHostRuntimeConnectionStatuses,
|
|
||||||
useHostRuntimeIsConnected,
|
|
||||||
useHosts,
|
|
||||||
} from "@/runtime/host-runtime";
|
|
||||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
|
||||||
import { usageCopy } from "./copy";
|
|
||||||
import {
|
|
||||||
groupUsageByHost,
|
|
||||||
replaceReport,
|
|
||||||
resolveUsagePill,
|
|
||||||
resolveUsageRefresh,
|
|
||||||
resolveUsageView,
|
|
||||||
type UsageHostGroup,
|
|
||||||
type UsagePill,
|
|
||||||
type UsageQueryState,
|
|
||||||
type UsageRefresh,
|
|
||||||
} from "./model";
|
|
||||||
import type { UsageReportEntry, UsageView } from "./types";
|
|
||||||
|
|
||||||
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
|
|
||||||
// an explicit refresh passes `forceRefresh` and reaches the source's API.
|
|
||||||
const REPORTS_STALE_TIME_MS = 60_000;
|
|
||||||
|
|
||||||
function usageReportsQueryKey(serverId: string) {
|
|
||||||
return ["usage", "reports", serverId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
|
|
||||||
return ["usage", "agent", serverId, agentId, model] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireClient(serverId: string) {
|
|
||||||
const client = getHostRuntimeStore().getClient(serverId);
|
|
||||||
if (!client) throw new Error(usageCopy.clientUnavailable);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listReports(serverId: string, forceRefresh = false): Promise<UsageReportEntry[]> {
|
|
||||||
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportQueryKey(serverId: string, reportId: string) {
|
|
||||||
return ["usage", "report", serverId, reportId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getReport(
|
|
||||||
serverId: string,
|
|
||||||
reportId: string,
|
|
||||||
forceRefresh = false,
|
|
||||||
): Promise<UsageReportEntry | null> {
|
|
||||||
return (
|
|
||||||
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
|
|
||||||
.reports[0] ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportsUsage(session: SessionState | undefined): boolean {
|
|
||||||
return session?.serverInfo?.features?.usageSources === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshReports(queryClient: QueryClient, serverId: string): Promise<void> {
|
|
||||||
await queryClient.fetchQuery({
|
|
||||||
queryKey: usageReportsQueryKey(serverId),
|
|
||||||
queryFn: async () => {
|
|
||||||
const reports = await listReports(serverId, true);
|
|
||||||
for (const report of reports)
|
|
||||||
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
|
|
||||||
return reports;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toQueryState(query: {
|
|
||||||
data: UsageReportEntry[] | undefined;
|
|
||||||
error: unknown;
|
|
||||||
isFetching: boolean;
|
|
||||||
}): UsageQueryState {
|
|
||||||
return { data: query.data, error: query.error, isFetching: query.isFetching };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Usage reports for one host, as shown on its settings page. */
|
|
||||||
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
||||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
|
||||||
const query = useFetchQuery({
|
|
||||||
queryKey: usageReportsQueryKey(serverId),
|
|
||||||
queryFn: async () => {
|
|
||||||
const reports = await listReports(serverId);
|
|
||||||
for (const report of reports)
|
|
||||||
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
|
|
||||||
return reports;
|
|
||||||
},
|
|
||||||
enabled: isConnected && isSupported,
|
|
||||||
dataShape: "list",
|
|
||||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
|
||||||
});
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
void refreshReports(queryClient, serverId).catch(() => undefined);
|
|
||||||
}, [queryClient, serverId]);
|
|
||||||
const view = resolveUsageView({
|
|
||||||
isConnected,
|
|
||||||
supportsUsage: isSupported,
|
|
||||||
query: toQueryState(query),
|
|
||||||
});
|
|
||||||
return { view, refresh };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Usage reports for every connected host, grouped by host. */
|
|
||||||
export function useUsageByHost(): {
|
|
||||||
groups: UsageHostGroup[];
|
|
||||||
refresh: (serverId: string) => void;
|
|
||||||
} {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const hosts = useHosts();
|
|
||||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
|
||||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
|
||||||
const supportedServerIds = useSessionStore(
|
|
||||||
useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))),
|
|
||||||
);
|
|
||||||
const usageHosts = useMemo(
|
|
||||||
() =>
|
|
||||||
hosts.map((host) => ({
|
|
||||||
serverId: host.serverId,
|
|
||||||
label: host.label,
|
|
||||||
isConnected: connectionStatuses.get(host.serverId) === "online",
|
|
||||||
supportsUsage: supportedServerIds.includes(host.serverId),
|
|
||||||
})),
|
|
||||||
[connectionStatuses, hosts, supportedServerIds],
|
|
||||||
);
|
|
||||||
const results = useFetchQueries<UsageReportEntry[]>(
|
|
||||||
usageHosts.map((host) => ({
|
|
||||||
queryKey: usageReportsQueryKey(host.serverId),
|
|
||||||
queryFn: () => listReports(host.serverId),
|
|
||||||
enabled: host.isConnected && host.supportsUsage,
|
|
||||||
dataShape: "list",
|
|
||||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const groups = groupUsageByHost(
|
|
||||||
usageHosts,
|
|
||||||
new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])),
|
|
||||||
);
|
|
||||||
const refresh = useCallback(
|
|
||||||
(serverId: string) => {
|
|
||||||
void refreshReports(queryClient, serverId).catch(() => undefined);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
return { groups, refresh };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The usage report for the account an agent is spending. The daemon resolves the
|
|
||||||
* report ID, which is re-resolved when the model changes (a new key) and when a
|
|
||||||
* turn completes (the query is paused while the agent runs, and its data is always
|
|
||||||
* stale, so resuming refetches). The report itself is read from the shared cache.
|
|
||||||
*/
|
|
||||||
export function useAgentUsage(
|
|
||||||
serverId: string,
|
|
||||||
agentId: string,
|
|
||||||
): { pill: UsagePill | null; entry: UsageReportEntry | null } {
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
||||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
|
||||||
const { model, isRunning } = useSessionStore(
|
|
||||||
useShallow((state) => {
|
|
||||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
|
||||||
return { model: agent?.model ?? null, isRunning: agent?.status === "running" };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const identity = useFetchQuery({
|
|
||||||
queryKey: agentUsageQueryKey(serverId, agentId, model),
|
|
||||||
queryFn: async () =>
|
|
||||||
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
|
|
||||||
enabled: isConnected && isSupported && !isRunning,
|
|
||||||
dataShape: "value",
|
|
||||||
staleTimeMs: 0,
|
|
||||||
});
|
|
||||||
const reportId = identity.data ?? null;
|
|
||||||
const query = useFetchQuery({
|
|
||||||
queryKey: reportQueryKey(serverId, reportId ?? "none"),
|
|
||||||
queryFn: () => getReport(serverId, reportId!),
|
|
||||||
enabled: isConnected && isSupported && reportId !== null,
|
|
||||||
dataShape: "value",
|
|
||||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
|
||||||
});
|
|
||||||
const entry = isSupported ? (query.data ?? null) : null;
|
|
||||||
return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces the source to fetch one report, and only that report. The result replaces
|
|
||||||
* the report wherever it is cached — its own entry and its host's list — so every
|
|
||||||
* surface showing it moves together; until then the previous report stays on screen.
|
|
||||||
*/
|
|
||||||
export function useReportRefresh(
|
|
||||||
serverId: string,
|
|
||||||
reportId: string,
|
|
||||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () => getReport(serverId, reportId, true),
|
|
||||||
onSuccess: (report) => {
|
|
||||||
queryClient.setQueryData(reportQueryKey(serverId, reportId), report);
|
|
||||||
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
|
|
||||||
reports ? replaceReport(reports, reportId, report) : reports,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { mutate } = mutation;
|
|
||||||
const refresh = useCallback(() => mutate(), [mutate]);
|
|
||||||
return { refresh, refreshState: resolveUsageRefresh(mutation) };
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Gauge } from "lucide-react-native";
|
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
|
||||||
import { SvgIcon } from "@/components/provider-icons";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
function UsageSourceIconBase({
|
|
||||||
svg,
|
|
||||||
size,
|
|
||||||
color = "",
|
|
||||||
}: {
|
|
||||||
svg: string | null;
|
|
||||||
size: number;
|
|
||||||
color?: string;
|
|
||||||
}) {
|
|
||||||
if (!svg) return <Gauge size={size} color={color} />;
|
|
||||||
return <SvgIcon svg={svg} size={size} color={color} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase);
|
|
||||||
|
|
||||||
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
|
|
||||||
/** The source's catalog SVG, or a generic gauge when the source ships none. */
|
|
||||||
export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) {
|
|
||||||
return <ThemedUsageSourceIcon svg={svg} size={size} uniProps={mutedIconColor} />;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user