mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df081a06ed | ||
|
|
c6a4ae81c9 | ||
|
|
d91171954b | ||
|
|
f232d23e9a | ||
|
|
68e722ec2e | ||
|
|
3b41183606 | ||
|
|
690f5298cb | ||
|
|
68938b668e | ||
|
|
560e478ef5 | ||
|
|
4965af219f | ||
|
|
940dbfd242 | ||
|
|
4340607093 | ||
|
|
849a876bce | ||
|
|
f4ba16a0b3 | ||
|
|
9d010211a2 | ||
|
|
8b5201fedf | ||
|
|
05874e289a | ||
|
|
d0a30ed4da | ||
|
|
cf4509631b | ||
|
|
b85b44aea1 | ||
|
|
7bb7d4ed08 | ||
|
|
dd5df9ec61 | ||
|
|
8de52a3c99 | ||
|
|
dffde6ae8b | ||
|
|
a43c8d888f | ||
|
|
792715e762 | ||
|
|
827178df90 | ||
|
|
c54f20e53e | ||
|
|
c7e7e52b0c |
@@ -34,6 +34,7 @@ hub:
|
||||
- "packages/server/src/server/hub/**"
|
||||
|
||||
server:
|
||||
- "plugins/**"
|
||||
- "packages/server/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
|
||||
@@ -117,9 +117,15 @@ jobs:
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Verify built-in plugins from built output
|
||||
run: node --test scripts/builtin-plugins-dist.test.mjs
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run website tests
|
||||
run: npx vitest run packages/website/src --bail=1
|
||||
|
||||
- name: Install website test browsers
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
@@ -169,6 +175,10 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -31,6 +31,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+17
-1
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
|
||||
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
|
||||
Changing that root field is
|
||||
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
|
||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
@@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
|
||||
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
|
||||
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
|
||||
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
|
||||
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
|
||||
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
@@ -376,6 +386,12 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
|
||||
+6
-15
@@ -195,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
## Usage sources
|
||||
|
||||
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
|
||||
|
||||
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
- `providerId`, `displayName`, `status`, and optional `planLabel`
|
||||
- any number of `windows` such as Session, Weekly, or Biweekly
|
||||
- optional `balances` for credits, USD, requests, or tokens
|
||||
- optional `details` for provider-specific rows
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
### Credentials are read only
|
||||
|
||||
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
|
||||
|
||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||
|
||||
### Usage fetchers are read-only on credentials
|
||||
|
||||
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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,6 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
"plugins": {
|
||||
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
|
||||
"project": ["**/*.{ts,tsx}"]
|
||||
},
|
||||
".": {
|
||||
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
|
||||
Generated
+60
-27
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -6333,6 +6334,10 @@
|
||||
"resolved": "packages/app",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/builtin-plugins": {
|
||||
"resolved": "plugins",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/cli": {
|
||||
"resolved": "packages/cli",
|
||||
"link": true
|
||||
@@ -36410,7 +36415,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37476,12 +37481,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/server": "0.10.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/server": "0.10.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37728,10 +37733,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37742,7 +37747,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37986,7 +37991,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38882,7 +38887,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38911,14 +38916,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38928,8 +38933,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38951,7 +38956,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38978,7 +38983,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39196,16 +39201,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/highlight": "0.10.1",
|
||||
"@getpaseo/plugin": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/highlight": "0.10.0",
|
||||
"@getpaseo/plugin": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39238,6 +39243,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -40096,6 +40102,21 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
@@ -40110,7 +40131,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41712,6 +41733,18 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -21,6 +21,7 @@
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -66,7 +67,7 @@
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-relative-time-",
|
||||
title: "Relative time agent",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
|
||||
await resetSeededPageState(page);
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await openSessions(page);
|
||||
|
||||
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
hoverChatOutlinePrompt,
|
||||
chatOutlineRail,
|
||||
movePointerOffChatOutline,
|
||||
observePromptIndexRequests,
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
|
||||
const promptIndex = await observePromptIndexRequests(page);
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start an agent with an outline.");
|
||||
await creation.expectPromptVisible();
|
||||
|
||||
const agentIds = await creation.agentIds();
|
||||
expect(agentIds).toHaveLength(1);
|
||||
await promptIndex.waitForRequestFor(agentIds[0]!);
|
||||
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
|
||||
const committedAt = new Date();
|
||||
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
|
||||
await page.clock.install({ time: committedAt });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await page.getByRole("button", { name: /Commits/i }).click();
|
||||
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
});
|
||||
|
||||
async function createFeatureCommit(
|
||||
repoPath: string,
|
||||
authorDate = "2020-01-15T12:00:00Z",
|
||||
): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_AUTHOR_DATE: authorDate,
|
||||
GIT_COMMITTER_DATE: authorDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -338,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function recordCommandRequestsByAgentId(page: Page): string[] {
|
||||
const requests: string[] = [];
|
||||
const record = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
|
||||
if (!payload.includes("draftConfig")) requests.push(payload);
|
||||
};
|
||||
page.on("websocket", (socket) => socket.on("framesent", record));
|
||||
return requests;
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
@@ -359,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
|
||||
|
||||
await page.goto("/new");
|
||||
await expectNewWorkspaceProjectSelected(page, "Choose project");
|
||||
await composerLocator(page).fill("/");
|
||||
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
|
||||
await expect(popover).not.toContainText("/clear");
|
||||
await expect(popover).not.toContainText("/exit");
|
||||
expect(commandRequestsByAgentId).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
const ring = meter.locator("svg");
|
||||
const progressColor = await ring
|
||||
.locator("circle")
|
||||
.last()
|
||||
.evaluate((circle) => getComputedStyle(circle).stroke);
|
||||
const screenshot = await ring.screenshot();
|
||||
return ring.evaluate(
|
||||
async (_svg, { png, color }) => {
|
||||
const image = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${png}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(image.width, image.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, image.width, image.height);
|
||||
const [r, g, b] = color.match(/\d+/g)!.map(Number);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const i = (y * image.width + x) * 4;
|
||||
const distance =
|
||||
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
|
||||
if (distance < 40) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new Error(`No pixels painted in the progress colour ${color}`);
|
||||
}
|
||||
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
|
||||
},
|
||||
{ png: screenshot.toString("base64"), color: progressColor },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("context window meter", () => {
|
||||
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-meter-",
|
||||
title: "Context window meter e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
startRestartableHostDaemon,
|
||||
type RestartableHostDaemon,
|
||||
} from "../support/helpers/versioned-host-daemon";
|
||||
|
||||
const PREVIOUS_VERSION = "0.8.0";
|
||||
const UPDATED_VERSION = "0.9.1";
|
||||
const HOST_LABEL = "Version restart QA";
|
||||
const NO_RELOAD_MARKER = "before-restart";
|
||||
|
||||
interface ReloadMarkerWindow {
|
||||
__paseoE2eHostVersionMarker?: string;
|
||||
}
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
let hostDaemon: RestartableHostDaemon | null = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await hostDaemon?.dispose();
|
||||
hostDaemon = null;
|
||||
});
|
||||
|
||||
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
|
||||
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
|
||||
hostDaemon = host;
|
||||
const otherHostVersion =
|
||||
await test.step("the connected host reports its current daemon version", async () => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: host.serverId,
|
||||
label: HOST_LABEL,
|
||||
port: host.port,
|
||||
});
|
||||
const version = await readHelpHostVersion(page, getServerId());
|
||||
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
|
||||
await openHostPage(page, host);
|
||||
await expect(hostIdentity(page)).toContainText("Online");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
|
||||
return version;
|
||||
});
|
||||
|
||||
await test.step("the last known version remains on the host page when the host stops", async () => {
|
||||
await host.stop();
|
||||
await expect(hostIdentity(page)).toContainText("Error");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the host daemon restarts on the new version", async () => {
|
||||
await host.restartWithVersion(UPDATED_VERSION);
|
||||
});
|
||||
|
||||
await test.step("the host page badge follows the new version", async () => {
|
||||
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
|
||||
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
|
||||
await expectNoReloadSinceMarker(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
|
||||
await expect(row).toBeVisible();
|
||||
const text = await row.innerText();
|
||||
await page.keyboard.press("Escape");
|
||||
return text;
|
||||
}
|
||||
|
||||
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
|
||||
const text = await readHelpHostVersion(page, serverId);
|
||||
expect(text).toContain(`v${version}`);
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "host");
|
||||
}
|
||||
|
||||
function hostIdentity(page: Page) {
|
||||
return page.getByTestId("host-page-identity");
|
||||
}
|
||||
|
||||
function hostVersionBadge(page: Page, version: string) {
|
||||
return hostIdentity(page).getByText(`v${version}`, { exact: true });
|
||||
}
|
||||
|
||||
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
|
||||
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
|
||||
// captured Node-side constant is not defined there.
|
||||
await page.evaluate((value) => {
|
||||
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
|
||||
}, marker);
|
||||
}
|
||||
|
||||
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
|
||||
const marker = await page.evaluate(
|
||||
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
|
||||
);
|
||||
expect(marker).toBe(NO_RELOAD_MARKER);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { test, type Page } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -49,6 +49,7 @@ interface ImportFlowScenario {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
importSessionTimestamp: number;
|
||||
repoCleanup(): Promise<void>;
|
||||
unrelatedCleanup(): Promise<void>;
|
||||
}
|
||||
@@ -94,7 +95,7 @@ test.beforeAll(async () => {
|
||||
};
|
||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||
const importSessionId = "fixture-custom-title";
|
||||
await seedClaudeSessions({
|
||||
const importSessionTimestamp = await seedClaudeSessions({
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
@@ -108,6 +109,7 @@ test.beforeAll(async () => {
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
importSessionTimestamp,
|
||||
repoCleanup: repo.cleanup,
|
||||
unrelatedCleanup: unrelated.cleanup,
|
||||
};
|
||||
@@ -122,6 +124,19 @@ test.afterAll(async () => {
|
||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("an open Import Session row keeps its age current", async ({ page }) => {
|
||||
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
await flow.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
await expect(row).toContainText("1m ago");
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("4m ago");
|
||||
});
|
||||
|
||||
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
@@ -225,7 +240,7 @@ async function seedClaudeSessions(input: {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<number> {
|
||||
const sessions = [
|
||||
{
|
||||
cwd: input.projectRoot,
|
||||
@@ -290,4 +305,5 @@ async function seedClaudeSessions(input: {
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,25 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
|
||||
test.describe("provider usage settings", () => {
|
||||
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
sourceLabel: "OpenUsage 0.6.27",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh invalidates and refetches usage", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("one provider error does not collapse the usage list", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Error", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("71%")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-usage-tooltip-",
|
||||
title: "Provider usage tooltip e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("provider usage tooltip", () => {
|
||||
test("fetches usage when the context tooltip opens and renders the active provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: 42,
|
||||
remainingPct: 58,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
|
||||
await page.getByTestId("context-window-meter").hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Test plan")).toBeVisible();
|
||||
await expect(page.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("42%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 41 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
|
||||
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
interface SeededSchedule {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
async function seedPausedSchedule(
|
||||
workspace: SeededWorkspace,
|
||||
name: string,
|
||||
): Promise<SeededSchedule> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
const { id, createdAt } = result.schedule;
|
||||
const paused = await client.schedulePause({ id });
|
||||
if (paused.error) {
|
||||
throw new Error(paused.error);
|
||||
}
|
||||
return { id, createdAt: Date.parse(createdAt) };
|
||||
}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleDelete({ id });
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
|
||||
async function openSchedulesAt(page: Page, time: number): Promise<void> {
|
||||
await page.clock.install({ time });
|
||||
await page.goto(buildSchedulesRoute());
|
||||
}
|
||||
|
||||
async function letTimePass(page: Page, duration: string): Promise<void> {
|
||||
await page.clock.fastForward(duration);
|
||||
}
|
||||
|
||||
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
|
||||
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Schedule relative timestamps", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
const failures: unknown[] = [];
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Test cleanup failed");
|
||||
}
|
||||
});
|
||||
|
||||
test("an idle schedule row keeps its created age current", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
|
||||
|
||||
await openSchedulesAt(page, schedule.createdAt);
|
||||
await expectScheduleRowText(page, schedule.id, "Created just now");
|
||||
|
||||
await letTimePass(page, "03:00");
|
||||
|
||||
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").first().hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const beta: UsageReportEntry = {
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt,
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
|
||||
},
|
||||
});
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[alpha(31, twoHoursAgo()), beta],
|
||||
() => [
|
||||
alpha(58, new Date().toISOString()),
|
||||
{ ...beta, fetchedAt: new Date().toISOString() },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: undefined },
|
||||
{ forceRefresh: true, reportIds: ["alpha:a"] },
|
||||
]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await alphaRefresh.hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
});
|
||||
|
||||
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { status: "available", windows: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
});
|
||||
|
||||
test("tells the user to update a host without usage sources", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
function report(input: {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
report: Partial<UsageReportEntry["report"]>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined },
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage settings", () => {
|
||||
test("renders every report returned by usage.list_reports", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
planLabel: "Max",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
planLabel: "Coding plan",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "gamma",
|
||||
sourceLabel: "Gamma plan",
|
||||
report: { status: "error", error: "Gamma auth expired" },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh forces a fresh report", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(23) },
|
||||
}),
|
||||
],
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(64) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usage.waitForListRequests(2);
|
||||
|
||||
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
|
||||
import { openSettingsSection } from "./settings";
|
||||
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { loadSessionMessageReaders } from "./new-workspace";
|
||||
|
||||
export async function withStreamingMarkdownOutline(
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
@@ -229,3 +230,22 @@ async function requireBoundingBox(
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export async function observePromptIndexRequests(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
const agentIds: string[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = frames.client(payload);
|
||||
if (request?.type === "agent.timeline.list_prompts.request") {
|
||||
agentIds.push(request.agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
async waitForRequestFor(agentId: string) {
|
||||
await expect.poll(() => agentIds).toContain(agentId);
|
||||
},
|
||||
requestedAgentIds: () => [...new Set(agentIds)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) {
|
||||
)
|
||||
.toBe(count);
|
||||
},
|
||||
async agentIds() {
|
||||
await requests.settled();
|
||||
return (await project.client.fetchAgents()).entries
|
||||
.filter(({ agent }) => agent.workspaceId === workspaceId)
|
||||
.map(({ agent }) => agent.id);
|
||||
},
|
||||
async expectAgentTitle(title: string) {
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -41,6 +41,13 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAvailableHostDaemonPort(): Promise<number> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 90_000;
|
||||
let lastError: unknown = null;
|
||||
@@ -81,9 +88,7 @@ export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function openSettingsHostSection(
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
|
||||
agentRequests(): Array<{ agentId: string }>;
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
waitForAgentRequests(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
/**
|
||||
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
|
||||
usageSources?: boolean;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null;
|
||||
if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; payload?: Record<string, unknown> };
|
||||
} | null;
|
||||
const payload = envelope?.message?.payload;
|
||||
if (
|
||||
envelope?.type !== "session" ||
|
||||
envelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const features =
|
||||
typeof payload.features === "object" && payload.features !== null ? payload.features : {};
|
||||
return JSON.stringify({
|
||||
...envelope,
|
||||
message: {
|
||||
...envelope.message,
|
||||
payload: { ...payload, features: { ...features, usageSources: enabled } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pick<T>(values: T[], index: number): T {
|
||||
const value = values[Math.min(index, values.length - 1)];
|
||||
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function createCounter() {
|
||||
let count = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
return {
|
||||
increment() {
|
||||
count += 1;
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (count >= waiter.count) waiter.resolve();
|
||||
else waiters.push(waiter);
|
||||
}
|
||||
},
|
||||
waitFor(target: number): Promise<void> {
|
||||
if (count >= target) return Promise.resolve();
|
||||
return new Promise((resolve) => waiters.push({ count: target, resolve }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installUsageReportsFixture(
|
||||
page: Page,
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const agentRequests: Array<{ agentId: string }> = [];
|
||||
const listCounter = createCounter();
|
||||
const agentCounter = createCounter();
|
||||
const usageSources = options.usageSources ?? true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = getSessionMessage(message);
|
||||
const requestId = request?.requestId;
|
||||
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
|
||||
listRequests.push({
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
});
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const response = typeof scripted === "function" ? scripted() : scripted;
|
||||
if ("error" in response) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
|
||||
agentRequests.push({
|
||||
agentId: String(request.agentId),
|
||||
});
|
||||
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent.resolve_usage_report.response",
|
||||
payload: { requestId, reportId },
|
||||
},
|
||||
}),
|
||||
);
|
||||
agentCounter.increment();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
agentRequests: () => [...agentRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
waitForAgentRequests: (count) => agentCounter.waitFor(count),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
|
||||
__dirname,
|
||||
"../../../../server/src/server/test-utils/versioned-daemon.ts",
|
||||
);
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
|
||||
// A host daemon that can be replaced by a daemon reporting another version under the same
|
||||
// endpoint and serverId, the way an upgraded host comes back.
|
||||
export interface RestartableHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
stop(): Promise<void>;
|
||||
restartWithVersion(version: string): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
let current: VersionedHostDaemon | null = null;
|
||||
async function dispose(): Promise<void> {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
const { serverId } = current;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
async stop() {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
},
|
||||
async restartWithVersion(nextVersion) {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
|
||||
if (current.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
interface VersionedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
version: string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface VersionedHostDaemonOptions {
|
||||
version: string;
|
||||
port: number;
|
||||
paseoHomeRoot: string;
|
||||
}
|
||||
|
||||
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
|
||||
// removes the home root.
|
||||
async function startVersionedHostDaemon(
|
||||
options: VersionedHostDaemonOptions,
|
||||
): Promise<VersionedHostDaemon> {
|
||||
const child = spawnTsx(
|
||||
VERSIONED_DAEMON_ENTRYPOINT,
|
||||
[options.version, String(options.port), options.paseoHomeRoot],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.once("error", ready.reject);
|
||||
child.once("exit", () => {
|
||||
ready.reject(
|
||||
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
|
||||
);
|
||||
});
|
||||
child.once("message", (message) => {
|
||||
if (!isReadyMessage(message)) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve(message);
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
ready.reject(
|
||||
new Error(
|
||||
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
|
||||
),
|
||||
),
|
||||
READY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let stopped = false;
|
||||
async function stop(): Promise<void> {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await killProcessTree(child);
|
||||
}
|
||||
|
||||
try {
|
||||
const readyMessage = await ready.promise;
|
||||
if (readyMessage.port !== options.port) {
|
||||
throw new Error(
|
||||
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
serverId: readyMessage.serverId,
|
||||
port: readyMessage.port,
|
||||
version: options.version,
|
||||
stop,
|
||||
};
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// A replacement daemon can only bind the port the stopped daemon just released.
|
||||
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isPortAvailable(port)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"port" in message &&
|
||||
typeof message.port === "number" &&
|
||||
"serverId" in message &&
|
||||
typeof message.serverId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => {
|
||||
it("accepts only the authoritative timeline epoch", () => {
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
||||
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
|
||||
|
||||
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||
|
||||
export function shouldAcceptPromptIndexEpoch(
|
||||
timelineEpoch: string | null,
|
||||
indexEpoch: string,
|
||||
): boolean {
|
||||
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
||||
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
|
||||
return timelineEpoch === indexEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
|
||||
runtime.subscribeAgentTimeline.mockClear();
|
||||
});
|
||||
|
||||
it("waits for a served timeline before requesting its prompt index", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { rerender } = renderHook(
|
||||
({ agentId, timelineEpoch }) =>
|
||||
useChatOutline({
|
||||
agentId,
|
||||
serverId: "server-1",
|
||||
timelineEpoch,
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
|
||||
);
|
||||
|
||||
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
|
||||
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||
const second = deferred<{
|
||||
|
||||
@@ -77,8 +77,10 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
|
||||
// epoch, and its id names no agent the daemon knows.
|
||||
useEffect(() => {
|
||||
if (!isWeb || !enabled) {
|
||||
if (!isWeb || !enabled || timelineEpoch === null) {
|
||||
setIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/usage" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
@@ -892,6 +893,7 @@ function RootStack() {
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="usage" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { UsageScreen } from "@/usage";
|
||||
|
||||
export default function UsageRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<UsageScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
@@ -181,7 +181,6 @@ function SessionRow({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
@@ -283,7 +282,7 @@ function SessionRow({
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile />
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
@@ -316,9 +315,7 @@ function SessionRow({
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
|
||||
</View>
|
||||
) : null}
|
||||
<SessionRowTrailingAttention
|
||||
@@ -330,6 +327,15 @@ function SessionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActivityTime({ date, isMobile }: { date: Date; isMobile: boolean }) {
|
||||
const label = useTimeAgo(date);
|
||||
return (
|
||||
<Text style={isMobile ? styles.sessionMetaText : styles.columnMetaFixed} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
agents,
|
||||
isRefreshing = false,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
@@ -13,9 +10,6 @@ interface ContextWindowMeterProps {
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
serverId?: string;
|
||||
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
|
||||
provider?: string | null;
|
||||
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||
pending?: boolean;
|
||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||
@@ -101,30 +95,13 @@ export function ContextWindowMeter({
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
serverId,
|
||||
provider,
|
||||
pending = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
|
||||
serverId ?? null,
|
||||
{ enabled: isTooltipOpen },
|
||||
);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage().catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
|
||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||
@@ -140,7 +117,6 @@ export function ContextWindowMeter({
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -167,13 +143,7 @@ export function ContextWindowMeter({
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
open={isTooltipOpen}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
@@ -187,7 +157,6 @@ export function ContextWindowMeter({
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -209,6 +178,8 @@ export function ContextWindowMeter({
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
@@ -233,7 +204,6 @@ export function ContextWindowMeter({
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -256,9 +226,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
percentageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
@@ -309,7 +309,6 @@ function ImportSessionSheetRow({
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
@@ -344,9 +343,11 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{importing ? t("importSession.row.importing") : lastActivity}
|
||||
</Text>
|
||||
{importing ? (
|
||||
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
|
||||
) : (
|
||||
<ImportSessionActivityTime date={entry.lastActivityAt} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
@@ -365,6 +366,11 @@ function ImportSessionSheetRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
const label = useTimeAgo(new Date(date));
|
||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
serverId,
|
||||
entries,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
buildUsageRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
@@ -426,6 +430,25 @@ function SidebarHostPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const handlePress = useCallback(() => {
|
||||
if (isCompactLayout) showMobileAgent();
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompactLayout, showMobileAgent]);
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={handlePress}
|
||||
testID="sidebar-usage"
|
||||
label={t("settings.hostSections.usage")}
|
||||
icon={Gauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
@@ -489,6 +512,7 @@ function SidebarFooter({
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageButton theme={theme} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface PageLayoutProps {
|
||||
title?: string;
|
||||
/** Compact back button. Defaults to navigating back. */
|
||||
onBack?: () => void;
|
||||
testID?: string;
|
||||
titleTestID?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-width page with a centered content column. On desktop the title is a
|
||||
* document heading inside the page and the header only keeps the titlebar drag
|
||||
* region and window controls; on compact the title moves into a back header.
|
||||
*/
|
||||
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
const showTitle = !isCompact && title !== undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
|
||||
<View style={styles.content}>
|
||||
{showTitle ? (
|
||||
<View testID={titleTestID}>
|
||||
<Text style={styles.title} testID="page-title">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: theme.spacing[4],
|
||||
paddingTop: theme.spacing[6],
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
title: {
|
||||
// Line up with the section titles, which sit inset from their cards.
|
||||
marginLeft: theme.spacing[1],
|
||||
marginBottom: theme.spacing[6],
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
@@ -430,16 +430,10 @@ function renderProviderSheetFooter({
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
const metaStyle = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||
</Text>
|
||||
{fetchedAt || !isCompact ? (
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button
|
||||
@@ -477,6 +471,25 @@ function renderProviderSheetFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUpdatedLabel({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
}: {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
|
||||
const style = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
return (
|
||||
<Text style={style} numberOfLines={1}>
|
||||
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -609,18 +622,6 @@ export function ProviderDiagnosticSheet({
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
if (!providerEntry?.fetchedAt) return null;
|
||||
void clockTick;
|
||||
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||
}, [providerEntry?.fetchedAt, clockTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setQuery("");
|
||||
@@ -686,7 +687,7 @@ export function ProviderDiagnosticSheet({
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
|
||||
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
|
||||
|
||||
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
|
||||
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
|
||||
return createElement(SvgXml, { xml: svg, width: size, height: size, color });
|
||||
}
|
||||
|
||||
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
createElement(SvgXml, {
|
||||
xml: iconSvg,
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
});
|
||||
SvgIcon({ svg: iconSvg, size, color });
|
||||
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
||||
return SvgProviderIcon;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resolveScheduleTitle,
|
||||
scheduleProductName,
|
||||
} from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
@@ -96,16 +96,19 @@ function stateBadge(state: ScheduleDerivedState): {
|
||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||
// was created, when it last ran, and (only while it can still run) when it runs
|
||||
// next. Status lives on the badge, never repeated here.
|
||||
function buildMeta(
|
||||
schedule: ScheduleSummary,
|
||||
state: ScheduleDerivedState,
|
||||
serverName: string | undefined,
|
||||
singleHost: boolean,
|
||||
): string {
|
||||
function buildMeta(input: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
createdAgo: string;
|
||||
lastRunAgo: string;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}): string {
|
||||
const { schedule, state, serverName, singleHost } = input;
|
||||
const parts = [
|
||||
formatCadence(schedule.cadence),
|
||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||
`Created ${input.createdAgo}`,
|
||||
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
|
||||
];
|
||||
if (state === "active") {
|
||||
const next = formatNextRun(schedule.nextRunAt);
|
||||
@@ -119,6 +122,27 @@ function buildMeta(
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function ScheduleMeta({
|
||||
schedule,
|
||||
state,
|
||||
serverName,
|
||||
singleHost,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}) {
|
||||
const createdAgo = useTimeAgo(new Date(schedule.createdAt));
|
||||
const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null);
|
||||
const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost });
|
||||
return (
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({
|
||||
@@ -168,7 +192,6 @@ export function ScheduleRow({
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const productName = scheduleProductName(schedule);
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||
|
||||
const rowStyle = useCallback(
|
||||
@@ -206,9 +229,12 @@ export function ScheduleRow({
|
||||
<Text style={styles.target} numberOfLines={1}>
|
||||
{targetLabel}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
<ScheduleMeta
|
||||
schedule={schedule}
|
||||
state={state}
|
||||
serverName={serverName}
|
||||
singleHost={singleHost ?? false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ interface ToolbarButtonCommonProps extends Omit<
|
||||
> {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
/** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */
|
||||
tooltip?: ReactNode;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
shortcut?: ShortcutKey[][] | null;
|
||||
@@ -76,6 +78,7 @@ type ToolbarButtonProps = ToolbarButtonCommonProps &
|
||||
export function ToolbarButton({
|
||||
children,
|
||||
label,
|
||||
tooltip: tooltipBody,
|
||||
selected = false,
|
||||
compact = false,
|
||||
shortcut,
|
||||
@@ -105,7 +108,7 @@ export function ToolbarButton({
|
||||
const tooltip = (
|
||||
<TooltipContent side={tooltipSide} align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{tooltipBody ?? <Text style={styles.tooltipText}>{label}</Text>}
|
||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
commandDraft={composerState?.commandDraft}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
|
||||
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
expect(getLatest().composerState?.commandDraft).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
describe("buildDraftCommandTarget", () => {
|
||||
const codexSelection = {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
};
|
||||
|
||||
it("needs a project when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
cwd: " ",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toEqual({ status: "needs-project" });
|
||||
});
|
||||
|
||||
it("needs a provider when none is selected", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: { ...codexSelection, provider: null },
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "",
|
||||
effectiveThinkingOptionId: "",
|
||||
}),
|
||||
).toEqual({ status: "needs-provider" });
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type DraftKeyInput,
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
|
||||
export interface AgentInputDraft {
|
||||
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[applyProfileFeatureValues, formState],
|
||||
);
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
const commandDraft = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
})
|
||||
: undefined,
|
||||
buildDraftCommandTarget({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
}),
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
};
|
||||
}, [
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -358,7 +355,6 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftCommandTarget,
|
||||
buildDraftAgentControls,
|
||||
};
|
||||
|
||||
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type DraftAgentControlsProps,
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { UsageComposerPill } from "@/usage";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
@@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
||||
model: agent?.model ?? null,
|
||||
provider: agent?.provider ?? null,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -284,8 +284,6 @@ function renderContextWindowMeter(
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
pending: boolean,
|
||||
glyphSize: number,
|
||||
): ReactElement | null {
|
||||
@@ -299,8 +297,6 @@ function renderContextWindowMeter(
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
pending={pending}
|
||||
glyphSize={glyphSize}
|
||||
/>
|
||||
@@ -977,8 +973,8 @@ interface ComposerProps {
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig;
|
||||
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
|
||||
commandDraft?: DraftCommandTarget;
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void;
|
||||
onComposerHeightChange?: (height: number) => void;
|
||||
@@ -1268,7 +1264,7 @@ function ComposerContentImpl({
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
@@ -2085,8 +2081,6 @@ function ComposerContentImpl({
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
false,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
@@ -2094,15 +2088,18 @@ function ComposerContentImpl({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
],
|
||||
);
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
|
||||
[contextWindowMeter, hasAgent],
|
||||
() => (
|
||||
<>
|
||||
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
|
||||
{hasAgent ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
|
||||
</>
|
||||
),
|
||||
[agentId, contextWindowMeter, hasAgent, serverId],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
@@ -2349,7 +2346,7 @@ function ComposerContentImpl({
|
||||
setUserInput: replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: commandDraftConfig,
|
||||
draft: commandDraft,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
@@ -2358,7 +2355,7 @@ function ComposerContentImpl({
|
||||
replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
commandDraftConfig,
|
||||
commandDraft,
|
||||
buildOutgoingAttachments,
|
||||
attachments,
|
||||
runClientSlashCommand,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
@@ -318,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
serverId,
|
||||
@@ -568,16 +551,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
if (serverInfo) {
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,14 +4,13 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { CommitGraphNode } from "./commit-graph-node";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
@@ -26,7 +25,6 @@ export const CommitRow = memo(function CommitRow({
|
||||
commit,
|
||||
isFirst,
|
||||
isLast,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -49,7 +47,7 @@ export const CommitRow = memo(function CommitRow({
|
||||
{commit.subject}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<CommitTimestamp authorDate={commit.authorDate} />
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
@@ -57,6 +55,11 @@ export const CommitRow = memo(function CommitRow({
|
||||
);
|
||||
});
|
||||
|
||||
function CommitTimestamp({ authorDate }: { authorDate: string }) {
|
||||
const label = useTimeAgo(new Date(authorDate));
|
||||
return <Text style={styles.timestamp}>{label}</Text>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
@@ -40,11 +39,9 @@ function CommitsSectionSkeleton() {
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -77,7 +74,6 @@ function CommitsSectionContent({
|
||||
commit={commit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === workspaceCommits.length - 1}
|
||||
now={now}
|
||||
onCommitPress={onCommitPress}
|
||||
/>
|
||||
))}
|
||||
@@ -94,9 +90,6 @@ export function CommitsSection({
|
||||
}: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -104,20 +97,9 @@ export function CommitsSection({
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setNow(new Date());
|
||||
}
|
||||
onCollapsedChange?.(!collapsed);
|
||||
}, [collapsed, onCollapsedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !isPanelActive) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [collapsed, isPanelActive]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
@@ -160,9 +142,7 @@ export function CommitsSection({
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{collapsed ? null : (
|
||||
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||
)}
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
@@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput {
|
||||
setUserInput: (nextValue: string) => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
draft?: DraftCommandTarget;
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
@@ -117,18 +118,13 @@ type AvailableCommand =
|
||||
| { source: "plugin"; command: PluginClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig,
|
||||
): DraftCommandConfig | undefined {
|
||||
if (!draftConfig) {
|
||||
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
|
||||
if (draft?.status !== "ready") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const draftConfig = draft.config;
|
||||
const cwd = draftConfig.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
@@ -337,6 +333,35 @@ function resolveAutocompleteErrorMessage(args: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
||||
// commands by its config only, and not at all until that config is complete.
|
||||
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
||||
const queryDraftConfig = resolveDraftQueryConfig(draft);
|
||||
const isDraftContext = draft !== undefined;
|
||||
return {
|
||||
isDraftContext,
|
||||
queryDraftConfig,
|
||||
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAutocompleteEmptyText(args: {
|
||||
mode: AutocompleteMode;
|
||||
draft: DraftCommandTarget | undefined;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (args.mode === "file") {
|
||||
return args.t("agentAutocomplete.noFiles");
|
||||
}
|
||||
if (args.draft?.status === "needs-project") {
|
||||
return args.t("agentAutocomplete.chooseProjectForCommands");
|
||||
}
|
||||
if (args.draft?.status === "needs-provider") {
|
||||
return args.t("agentAutocomplete.chooseModelForCommands");
|
||||
}
|
||||
return args.t("agentAutocomplete.noCommands");
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig,
|
||||
draft,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
@@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
return () => clearTimeout(timer);
|
||||
}, [fileFilterQuery]);
|
||||
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
[draftConfig],
|
||||
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
|
||||
() => resolveDraftCommandContext(draft),
|
||||
[draft],
|
||||
);
|
||||
|
||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useSessionStore(
|
||||
@@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const {
|
||||
commands,
|
||||
isLoading: isCommandsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useAgentCommandsQuery({
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
const { commands, isError, error } = commandsQuery;
|
||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
||||
|
||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||
|
||||
@@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
mode === "file"
|
||||
? t("agentAutocomplete.searchingWorkspace")
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
const emptyText =
|
||||
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
|
||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface AgentSlashCommand {
|
||||
|
||||
export type DraftCommandConfig = AgentCommandsDraftConfig;
|
||||
|
||||
/** What a draft composer can list commands for, before an agent exists. */
|
||||
export type DraftCommandTarget =
|
||||
| { status: "ready"; config: DraftCommandConfig }
|
||||
| { status: "needs-project" }
|
||||
| { status: "needs-provider" };
|
||||
|
||||
interface ListAgentCommandsOptions {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
|
||||
+17
-5
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
||||
import { describeCompactTimeAgo } from "@/utils/time";
|
||||
import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time";
|
||||
|
||||
/** A prose relative timestamp ("5m ago") that keeps itself current. See `useCompactTimeAgo`. */
|
||||
export function useTimeAgo(date: Date | null): string {
|
||||
return useRelativeTimeLabel(date, describeTimeAgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact relative timestamp that keeps itself current.
|
||||
@@ -19,7 +24,14 @@ import { describeCompactTimeAgo } from "@/utils/time";
|
||||
* never changes again.
|
||||
*/
|
||||
export function useCompactTimeAgo(date: Date | null): string {
|
||||
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
|
||||
return useRelativeTimeLabel(date, describeCompactTimeAgo);
|
||||
}
|
||||
|
||||
function useRelativeTimeLabel(
|
||||
date: Date | null,
|
||||
describe: (date: Date) => RelativeTimeLabel,
|
||||
): string {
|
||||
const [label, setLabel] = useState(() => (date ? describe(date).label : ""));
|
||||
|
||||
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
|
||||
// depending on identity would tear down and rebuild the subscription for an unchanged time.
|
||||
@@ -32,13 +44,13 @@ export function useCompactTimeAgo(date: Date | null): string {
|
||||
}
|
||||
|
||||
const source = new Date(time);
|
||||
let current = describeCompactTimeAgo(source);
|
||||
let current = describe(source);
|
||||
setLabel(current.label);
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleTick = () => {
|
||||
const next = describeCompactTimeAgo(source);
|
||||
const next = describe(source);
|
||||
if (next.label !== current.label) {
|
||||
setLabel(next.label);
|
||||
}
|
||||
@@ -62,7 +74,7 @@ export function useCompactTimeAgo(date: Date | null): string {
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [time]);
|
||||
}, [time, describe]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -1522,6 +1522,8 @@ export const ar: TranslationResources = {
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "تعذر تحميل السجل الأقدم",
|
||||
|
||||
@@ -1547,6 +1547,8 @@ export const en = {
|
||||
noFiles: "No files or directories found",
|
||||
noCommands: "No commands found",
|
||||
failedToLoad: "Failed to load",
|
||||
chooseProjectForCommands: "Choose a project to see commands",
|
||||
chooseModelForCommands: "Select a model to see commands",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Couldn't load older history",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const es: TranslationResources = {
|
||||
noFiles: "No se encontraron archivos ni directorios",
|
||||
noCommands: "No se encontraron comandos",
|
||||
failedToLoad: "No se pudo cargar",
|
||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "No se pudo cargar el historial anterior",
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const fr: TranslationResources = {
|
||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||
noCommands: "Aucune commande trouvée",
|
||||
failedToLoad: "Échec du chargement",
|
||||
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
|
||||
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Impossible de charger l'ancien historique",
|
||||
|
||||
@@ -1539,6 +1539,8 @@ export const ja: TranslationResources = {
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "古い履歴を読み込めませんでした",
|
||||
|
||||
@@ -1532,6 +1532,8 @@ export const ko: TranslationResources = {
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "이전 기록을 불러올 수 없습니다",
|
||||
|
||||
@@ -1553,6 +1553,8 @@ export const ptBR: TranslationResources = {
|
||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||
noCommands: "Nenhum comando encontrado",
|
||||
failedToLoad: "Falha ao carregar",
|
||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Não foi possível carregar o histórico mais antigo",
|
||||
|
||||
@@ -1551,6 +1551,8 @@ export const ru: TranslationResources = {
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Не удалось загрузить старую историю.",
|
||||
|
||||
@@ -1505,6 +1505,8 @@ export const zhCN: TranslationResources = {
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "无法加载更早历史",
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { filterSelectableModels } from "./model-catalog";
|
||||
@@ -286,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId(
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
export function buildDraftCommandTarget(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
}): DraftCommandTarget {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
if (!cwd) {
|
||||
return { status: "needs-project" };
|
||||
}
|
||||
if (!input.selection.provider) {
|
||||
return { status: "needs-provider" };
|
||||
}
|
||||
|
||||
return {
|
||||
const config = {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
@@ -310,6 +313,7 @@ export function buildDraftCommandConfig(input: {
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
return { status: "ready", config };
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||
import { formatAgo } from "./format";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { ProviderUsageWindowBar } from "./window-bar";
|
||||
|
||||
interface ProviderUsageIconProps {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||
|
||||
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function statusText(usage: ProviderUsage): string | null {
|
||||
if (usage.status === "available") return null;
|
||||
return usage.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function footerText(usage: ProviderUsage): string | null {
|
||||
const updated = formatAgo(usage.fetchedAt);
|
||||
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function ProviderUsageCard({
|
||||
usage,
|
||||
compact = false,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const status = statusText(usage);
|
||||
const footer = footerText(usage);
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
[compact],
|
||||
);
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
usage.status === "available" && styles.statusDotAvailable,
|
||||
usage.status === "error" && styles.statusDotError,
|
||||
],
|
||||
[usage.status],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.header}>
|
||||
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{usage.displayName}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<View style={styles.details}>
|
||||
{details.map((detail) => (
|
||||
<View key={detail.id} style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{detail.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<Text style={styles.footer} numberOfLines={1}>
|
||||
{footer}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
containerPadded: {
|
||||
gap: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
containerCompact: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
name: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDotAvailable: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
statusDotError: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
bars: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
detailLabel: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
detailValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
footer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -1,18 +0,0 @@
|
||||
// User-facing copy for the provider-usage surfaces, centralized so localization
|
||||
// is a single-file change. INTEGRATION: move these into the i18n resources
|
||||
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
|
||||
// call sites once the feature is wired to data. Kept inline for now because the
|
||||
// surfaces are not yet mounted and the locale files are being edited elsewhere.
|
||||
export const providerUsageCopy = {
|
||||
title: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to this host to see provider usage",
|
||||
hostUpgradeRequired: "Update the host to see provider usage",
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
tooltipLoading: "Loading plan usage…",
|
||||
} as const;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import { ProviderUsageList } from "./list";
|
||||
import type { ProviderUsageView } from "./types";
|
||||
|
||||
export function ProviderUsageSettingsSection({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={RefreshCw}
|
||||
loading={busy}
|
||||
onPress={onRefresh}
|
||||
accessibilityLabel={providerUsageCopy.refresh}
|
||||
>
|
||||
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
|
||||
</Button>
|
||||
),
|
||||
[busy, onRefresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={providerUsageCopy.title}
|
||||
testID="provider-usage-card"
|
||||
trailing={refreshButton}
|
||||
>
|
||||
<ProviderUsageBody view={view} onRefresh={onRefresh} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUsageBody({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={providerUsageCopy.errorTitle}
|
||||
description={view.message}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.payload.providers.length === 0) {
|
||||
return (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProviderUsageList providers={view.payload.providers} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsage, ProviderUsageView } from "./types";
|
||||
|
||||
function matchProvider(
|
||||
providers: ProviderUsage[],
|
||||
activeProviderId: string | null | undefined,
|
||||
): ProviderUsage | null {
|
||||
if (!activeProviderId) return null;
|
||||
const target = activeProviderId.toLowerCase();
|
||||
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
|
||||
}
|
||||
|
||||
// Renders the active agent's provider usage inside the context-meter tooltip.
|
||||
// Returns nothing when the active provider has no usage entry, so the meter's
|
||||
// own context section stays the whole tooltip.
|
||||
export function ProviderUsageTooltipSection({
|
||||
view,
|
||||
activeProviderId,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
activeProviderId: string | null | undefined;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.error}>{view.message}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const usage = matchProvider(view.payload.providers, activeProviderId);
|
||||
if (!usage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<ProviderUsageCard usage={usage} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: 1,
|
||||
// Same token the popover draws its own outline with, so the rule reads as the
|
||||
// popover's edge. `border` is invisible here (equals the popover background).
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
marginVertical: theme.spacing[2],
|
||||
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,26 +0,0 @@
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
};
|
||||
|
||||
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
|
||||
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
|
||||
export type ProviderUsageView =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
|
||||
@@ -1,103 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -158,17 +158,28 @@ class FakeDaemonClient {
|
||||
}
|
||||
|
||||
public ownedSubscriptions = true;
|
||||
private daemonVersion: string | null = "0.8.0";
|
||||
|
||||
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
|
||||
if (this.daemonVersion === null) return null;
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
hostname: "test",
|
||||
version: "0.8.0",
|
||||
version: this.daemonVersion,
|
||||
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 {
|
||||
this.listeners.add(listener);
|
||||
listener(this.state);
|
||||
@@ -2129,6 +2140,37 @@ describe("HostRuntimeStore", () => {
|
||||
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 () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_legacy_transitions",
|
||||
|
||||
@@ -50,6 +50,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
useSessionStore,
|
||||
toDaemonServerInfo,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type ProjectDescriptor,
|
||||
@@ -2266,6 +2267,13 @@ export class HostRuntimeStore {
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(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 {
|
||||
@@ -2653,21 +2661,22 @@ export function useHostRuntimeConnectionStatuses(
|
||||
serverIds: readonly string[],
|
||||
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||
const store = getHostRuntimeStore();
|
||||
const version = useSyncExternalStore(
|
||||
// The snapshot is the statuses themselves, joined into a string so React compares by
|
||||
// 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),
|
||||
() => store.getVersion(),
|
||||
() => store.getVersion(),
|
||||
readStatuses,
|
||||
readStatuses,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
// The aggregate version is the reactivity trigger; re-read snapshots on every host tick.
|
||||
void version;
|
||||
const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [
|
||||
serverId,
|
||||
store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
||||
]);
|
||||
return new Map(entries);
|
||||
}, [serverIds, store, version]);
|
||||
const values = statuses.split("\n") as HostRuntimeConnectionStatus[];
|
||||
return new Map(serverIds.map((serverId, index) => [serverId, values[index]]));
|
||||
}, [serverIds, statuses]);
|
||||
}
|
||||
|
||||
export function useHostRuntimeLastError(serverId: string): string | null {
|
||||
|
||||
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
commandDraft={composerState?.commandDraft}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { PageLayout } from "@/components/page-layout";
|
||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
|
||||
@@ -1305,30 +1305,25 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
}, []);
|
||||
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
titleAccessory?: ReactNode;
|
||||
} | null => {
|
||||
const detailTitle = ((): string | undefined => {
|
||||
if (view.kind === "plugin") {
|
||||
const screen = installedPlugins
|
||||
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
||||
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` };
|
||||
return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`;
|
||||
}
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey) };
|
||||
return item ? t(item.labelKey) : undefined;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey) };
|
||||
return item ? t(item.labelKey) : undefined;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return { title: t("settings.projects") };
|
||||
return t("settings.projects");
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const content: ReactNode = (() => {
|
||||
@@ -1399,15 +1394,6 @@ 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 = (
|
||||
<>
|
||||
<AddHostMethodModal
|
||||
@@ -1464,14 +1450,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title={detailHeader?.title}
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={handleBackFromDetail}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||
{content}
|
||||
</PageLayout>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
@@ -1497,15 +1478,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
{/* Keeps the titlebar drag region and window controls; the page
|
||||
title lives in the content, like a document heading. */}
|
||||
<ScreenHeader borderless />
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>
|
||||
{desktopPageTitle}
|
||||
{content}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<PageLayout title={detailTitle} titleTestID="settings-detail-header-title">
|
||||
{content}
|
||||
</PageLayout>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
@@ -1536,26 +1511,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
scrollView: {
|
||||
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: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -50,8 +50,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { HostUsageSection } from "@/usage";
|
||||
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -343,10 +342,6 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
|
||||
export function HostUsagePage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refreshProviderUsage();
|
||||
}, [refreshProviderUsage]);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
@@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
|
||||
<HostUsageSection serverId={serverId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
||||
@@ -148,12 +149,6 @@ function formatAgentTooltipTitle(singleLineTitle: string): string {
|
||||
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({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -169,7 +164,7 @@ function AgentTabTooltipBody({
|
||||
return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null;
|
||||
});
|
||||
const compactActivity = useCompactTimeAgo(lastActivityAt);
|
||||
const activity = formatAgentTooltipActivity(compactActivity);
|
||||
const activity = formatCompactTimeAgoAsProse(compactActivity);
|
||||
|
||||
return (
|
||||
<View style={styles.tooltipAgentContent}>
|
||||
|
||||
@@ -285,6 +285,19 @@ export interface DaemonServerInfo {
|
||||
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 {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
|
||||
import type { UsageBalance, UsageTone } from "./types";
|
||||
|
||||
interface ResolvedBalance {
|
||||
amountText: string;
|
||||
usedPct: number | null;
|
||||
}
|
||||
|
||||
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
|
||||
function resolveBalance(balance: UsageBalance): ResolvedBalance {
|
||||
const { used, remaining, limit, unit } = balance;
|
||||
if (limit != null && limit > 0) {
|
||||
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
||||
@@ -26,7 +26,7 @@ function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
|
||||
return { amountText: "—", usedPct: null };
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: ProviderUsageTone) {
|
||||
function fillToneStyle(tone: UsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
@@ -39,7 +39,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
|
||||
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
|
||||
const { amountText, usedPct } = resolveBalance(balance);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
@@ -0,0 +1,308 @@
|
||||
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),
|
||||
}));
|
||||
@@ -0,0 +1,82 @@
|
||||
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],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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,5 +1,5 @@
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import type { ProviderUsageBalanceUnit } from "./types";
|
||||
import type { UsageBalanceUnit } from "./types";
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
@@ -28,20 +28,7 @@ export function formatResetLabel(iso: string | null | undefined): string | null
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
export function formatAmount(value: number, unit: UsageBalanceUnit): string {
|
||||
switch (unit) {
|
||||
case "usd":
|
||||
return `$${value.toFixed(2)}`;
|
||||
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UsageComposerPill } from "./composer-pill";
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
@@ -2,16 +2,22 @@ import { Fragment } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { UsageCard } from "./card";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
|
||||
export function UsageList({
|
||||
serverId,
|
||||
reports,
|
||||
}: {
|
||||
serverId: string;
|
||||
reports: UsageReportEntry[];
|
||||
}) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{providers.map((usage, index) => (
|
||||
<Fragment key={usage.providerId}>
|
||||
{reports.map((entry, index) => (
|
||||
<Fragment key={entry.id}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<ProviderUsageCard usage={usage} />
|
||||
<UsageCard serverId={serverId} entry={entry} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
@@ -0,0 +1,211 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderUsageTone } from "./types";
|
||||
import type { UsageTone } from "./types";
|
||||
|
||||
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
|
||||
export function deriveTone(usedPct: number | null | undefined): UsageTone {
|
||||
if (usedPct == null) return "default";
|
||||
if (usedPct > 90) return "danger";
|
||||
if (usedPct >= 70) return "warning";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { UsageReportEntry };
|
||||
export type UsageReport = UsageReportEntry["report"];
|
||||
export type UsageTone = ProviderUsageTone;
|
||||
export type UsageWindow = UsageReport["windows"][number];
|
||||
export type UsageBalance = NonNullable<UsageReport["balances"]>[number];
|
||||
export type UsageBalanceUnit = UsageBalance["unit"];
|
||||
|
||||
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
|
||||
export type UsageView =
|
||||
| { kind: "unavailable"; message: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user