mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119afd7281 | ||
|
|
e58725ee39 | ||
|
|
36cdfaf516 | ||
|
|
c5442ef0a2 | ||
|
|
0748149ec9 | ||
|
|
8c67415fdb | ||
|
|
6fe320055d | ||
|
|
2ef119c24b | ||
|
|
79be6d8dba | ||
|
|
42e3f63dec | ||
|
|
7a817774b7 | ||
|
|
12101914c2 | ||
|
|
f94b488c4f | ||
|
|
9170c2f0e6 | ||
|
|
78d46a8a82 | ||
|
|
1970a14349 | ||
|
|
a397d411dd | ||
|
|
d9cd6ea0fd | ||
|
|
c03e7b82b4 | ||
|
|
26b2f25050 | ||
|
|
2b5cc727f0 | ||
|
|
18f880e561 | ||
|
|
cf4dd8616c | ||
|
|
617cf8a7bf | ||
|
|
9c86a410ea | ||
|
|
2d8acc1611 | ||
|
|
f2e7ac2dc1 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.100 - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Cycle agent modes with Shift+Tab
|
||||
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
|
||||
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
|
||||
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
|
||||
|
||||
## 0.1.99 - 2026-06-23
|
||||
|
||||
### Improved
|
||||
|
||||
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
|
||||
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
|
||||
|
||||
### Fixed
|
||||
|
||||
- ACP providers like Grok no longer show duplicate user messages
|
||||
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
|
||||
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
|
||||
|
||||
## 0.1.98 - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -49,6 +49,38 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Expo Router layout ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory. In the root
|
||||
layout, register `h/[serverId]`; do not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
|
||||
routes with its own nested stack and relative screen names:
|
||||
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
|
||||
warns with `[Layout children]: No route named ...` when a layout registers
|
||||
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
|
||||
leave a nested index route mounted without its local dynamic params and render a
|
||||
blank screen.
|
||||
|
||||
Do not paper over missing required route params by reading global params in the
|
||||
leaf. Required dynamic params belong to the matched route. If
|
||||
`useLocalSearchParams()` misses one, fix the layout ownership.
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree. Put shared route policy in
|
||||
`src/navigation`, `src/utils`, or another non-route directory.
|
||||
|
||||
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
|
||||
workspace for that host after the workspace-selection store hydrates unless the
|
||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||
remembered workspace go to `open-project`.
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId`/
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
||||
|
||||
@@ -10,6 +10,8 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
|
||||
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
@@ -26,15 +28,17 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -42,6 +46,8 @@ Provider session import has its own contract. The picker calls `listImportableSe
|
||||
|
||||
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
|
||||
|
||||
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
|
||||
|
||||
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
|
||||
|
||||
---
|
||||
@@ -336,14 +342,13 @@ interface AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listImportableSessions?(
|
||||
listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession?(
|
||||
importSession(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
|
||||
@@ -13,6 +13,15 @@ Each domain becomes a controller class in its own file with the **exact** contra
|
||||
|
||||
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
|
||||
|
||||
## Progress (shipped — diverged from the original filenames)
|
||||
|
||||
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
@@ -117,7 +126,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
|
||||
|
||||
## Slice 7 — VoiceSessionController (XL)
|
||||
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
|
||||
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
|
||||
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35243,7 +35243,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -35566,12 +35566,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/server": "0.1.98",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/server": "0.1.100",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35817,10 +35817,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/relay": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35831,7 +35831,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36074,7 +36074,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -36970,7 +36970,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37201,7 +37201,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37213,7 +37213,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37431,15 +37431,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.98",
|
||||
"@getpaseo/highlight": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/relay": "0.1.98",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/highlight": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -37848,7 +37848,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -228,6 +228,7 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await returnToWorkspaceChanges(page);
|
||||
await expectStoredCodeFontSize(page, 18);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
@@ -238,6 +239,9 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
@@ -270,10 +274,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const actualFontSize = await page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
expect(actualFontSize).toBe(fontSize);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
@@ -400,6 +407,22 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number):
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
await expectStoredCodeFontSize(page, codeFontSize);
|
||||
}
|
||||
|
||||
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(
|
||||
(settingsKey) => localStorage.getItem(settingsKey),
|
||||
APP_SETTINGS_KEY,
|
||||
);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
|
||||
})
|
||||
.toBe(codeFontSize);
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
@@ -54,7 +56,7 @@ import {
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -465,14 +467,26 @@ function AppContainer({
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
@@ -863,8 +877,6 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -889,19 +901,7 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
{/*
|
||||
Do not add getId or dangerouslySingular back to the workspace route.
|
||||
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||
already-mounted workspace screen. Keep workspace identity/retention
|
||||
outside this route-level native-stack API.
|
||||
*/}
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return <KnownHostRoute />;
|
||||
}
|
||||
@@ -24,8 +32,21 @@ function KnownHostRoute() {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
// Keep the host Slot mounted while startup gates are active. React Navigation
|
||||
// web can reserialize a shallower tree and drop nested workspace URL segments
|
||||
// if the layout swaps Slot for a splash; leaf routes own the splash boundary.
|
||||
return <Slot />;
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
return stack;
|
||||
}
|
||||
|
||||
return <HostRouteProvider serverId={routeServerId}>{stack}</HostRouteProvider>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
if (!serverId) return null;
|
||||
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
if (!serverId || !isWorkspaceSelectionLoaded) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
href={resolveHostIndexRoute({
|
||||
serverId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import React from "react";
|
||||
import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
resolveStartupRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
@@ -20,6 +24,13 @@ export default function Index() {
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionServerId = workspaceSelection?.serverId ?? null;
|
||||
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
|
||||
const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(
|
||||
workspaceSelectionServerId,
|
||||
workspaceSelectionWorkspaceId,
|
||||
);
|
||||
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "index", pathname },
|
||||
@@ -28,6 +39,10 @@ export default function Index() {
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(
|
||||
overrides: Partial<CompactExplorerSidebarHostModel> = {},
|
||||
): CompactExplorerSidebarHostModel {
|
||||
return {
|
||||
serverId: overrides.serverId ?? "server-1",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-a",
|
||||
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
|
||||
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
|
||||
isGit: overrides.isGit ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCompactExplorerSidebarHostModel", () => {
|
||||
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/a",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches ownership to the active workspace instead of leaking the previous one", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-b" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
persistenceKey: "server-1:workspace-b",
|
||||
workspaceRoot: "",
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retain a previous owner when there is no active workspace selection", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: createModel(),
|
||||
selection: null,
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the current workspace directory when it is available", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: null,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
|
||||
isGit: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/current",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface CompactExplorerSidebarHostModel {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
persistenceKey: string;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
interface ResolveCompactExplorerSidebarHostModelInput {
|
||||
previous: CompactExplorerSidebarHostModel | null;
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveCompactExplorerSidebarHostModel(
|
||||
input: ResolveCompactExplorerSidebarHostModelInput,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const serverId = trimNonEmpty(input.selection?.serverId);
|
||||
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousForSelection =
|
||||
input.previous &&
|
||||
input.previous.serverId === serverId &&
|
||||
input.previous.workspaceId === workspaceId
|
||||
? input.previous
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
workspaceId,
|
||||
persistenceKey,
|
||||
workspaceRoot:
|
||||
trimNonEmpty(input.workspace?.workspaceDirectory) ??
|
||||
previousForSelection?.workspaceRoot ??
|
||||
"",
|
||||
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
|
||||
};
|
||||
}
|
||||
162
packages/app/src/components/compact-explorer-sidebar-host.tsx
Normal file
162
packages/app/src/components/compact-explorer-sidebar-host.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
|
||||
interface CompactExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
onOpenExplorer: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
function CompactExplorerOpenGestureSurface({
|
||||
children,
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveCompactExplorerSidebarModel(
|
||||
enabled: boolean,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused: enabled && selection !== null,
|
||||
normalizedServerId: selection?.serverId ?? "",
|
||||
normalizedWorkspaceId: selection?.workspaceId ?? "",
|
||||
workspaceDirectory: workspace?.workspaceDirectory || null,
|
||||
});
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerOpen) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerOpen) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout: {
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
},
|
||||
});
|
||||
}, [model, openFileExplorerForCheckout]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
persistenceKey: model.persistenceKey,
|
||||
showMobileAgent,
|
||||
openWorkspaceTabFocused,
|
||||
focusWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{enabled && model ? (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
} as const;
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -16,9 +15,13 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -28,8 +31,9 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -49,7 +53,29 @@ interface ExplorerSidebarProps {
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({
|
||||
interface ExplorerSidebarSharedState {
|
||||
explorerTab: ExplorerTab;
|
||||
handleTabPress: (tab: ExplorerTab) => void;
|
||||
}
|
||||
|
||||
function useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
return { explorerTab, handleTabPress };
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
@@ -57,40 +83,22 @@ export function ExplorerSidebar({
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: isMobile,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
}
|
||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -103,23 +111,15 @@ export function ExplorerSidebar({
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
// For resize drag, track the starting width
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
});
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
return;
|
||||
}
|
||||
closeDesktopFileExplorer();
|
||||
showMobileAgent();
|
||||
},
|
||||
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
@@ -128,24 +128,14 @@ export function ExplorerSidebar({
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(enableSidebarCloseGesture)
|
||||
.enabled(true)
|
||||
// Use manual activation so child views keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
@@ -219,7 +209,6 @@ export function ExplorerSidebar({
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
enableSidebarCloseGesture,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -234,32 +223,6 @@ export function ExplorerSidebar({
|
||||
],
|
||||
);
|
||||
|
||||
// Desktop resize gesture (drag left edge)
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(!isMobile)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging left (negative translationX) increases width
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
@@ -268,10 +231,6 @@ export function ExplorerSidebar({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
|
||||
const backdropCombinedStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.backdrop,
|
||||
@@ -312,10 +271,6 @@ export function ExplorerSidebar({
|
||||
],
|
||||
[overlayVisible],
|
||||
);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
@@ -324,39 +279,101 @@ export function ExplorerSidebar({
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
if (!isScreenFocused) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
}
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
}
|
||||
}, [explorerWidth, setExplorerWidth, viewportWidth]);
|
||||
|
||||
const handleDesktopClose = useCallback(() => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason: "desktop-close-button",
|
||||
isOpen,
|
||||
});
|
||||
closeDesktopFileExplorer();
|
||||
}, [closeDesktopFileExplorer, isOpen]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
|
||||
// Desktop: fixed width sidebar with resize handle
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -364,12 +381,11 @@ export function ExplorerSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View style={RESIZE_HANDLE_STYLE} />
|
||||
</GestureDetector>
|
||||
|
||||
<SidebarContent
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleDesktopClose}
|
||||
@@ -427,7 +443,7 @@ interface SidebarContentProps {
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
function ExplorerSidebarContent({
|
||||
activeTab,
|
||||
onTabPress,
|
||||
onClose,
|
||||
@@ -441,6 +457,7 @@ function SidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
@@ -450,11 +467,17 @@ function SidebarContent({
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const handlePrRetry = useCallback(() => {
|
||||
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
@@ -487,7 +510,7 @@ function SidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
{isGit && hasPullRequest && (
|
||||
{isGit && showPrTab && (
|
||||
<ExplorerTabButton
|
||||
tab="pr"
|
||||
active={resolvedTab === "pr"}
|
||||
@@ -531,12 +554,13 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
prPane={prPane}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
onRetry={handlePrRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -544,6 +568,38 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
onRetry,
|
||||
}: PrTabContentProps) {
|
||||
if (prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prPane.error) {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
|
||||
@@ -600,13 +600,20 @@ export function ProviderDiagnosticSheet({
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
|
||||
if (providerEntry?.models && providerEntry.models.length > 0) {
|
||||
stableDiscoveredRef.current = providerEntry.models;
|
||||
const currentModels = providerEntry?.models;
|
||||
if (currentModels && currentModels.length > 0) {
|
||||
stableDiscoveredRef.current = currentModels;
|
||||
}
|
||||
const discoveredModels =
|
||||
providerEntry?.models && providerEntry.models.length > 0
|
||||
? providerEntry.models
|
||||
: stableDiscoveredRef.current;
|
||||
|
||||
const discoveredModels = useMemo(() => {
|
||||
if (currentModels && currentModels.length > 0) {
|
||||
return currentModels;
|
||||
}
|
||||
if (providerSnapshotRefreshing) {
|
||||
return stableDiscoveredRef.current;
|
||||
}
|
||||
return [];
|
||||
}, [currentModels, providerSnapshotRefreshing]);
|
||||
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
@@ -106,7 +113,10 @@ function AgentModeControlView({
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -146,6 +156,26 @@ function AgentModeControlView({
|
||||
[onSelectMode, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "message-input.mode-cycle") return false;
|
||||
if (disabled || !isActiveComposer) return false;
|
||||
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
|
||||
if (!nextModeId) return false;
|
||||
onSelectMode(nextModeId);
|
||||
return true;
|
||||
},
|
||||
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: ["message-input.mode-cycle"],
|
||||
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
|
||||
priority: 200,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const renderOption = useCallback(
|
||||
(args: {
|
||||
option: ComboboxOption;
|
||||
@@ -194,21 +224,31 @@ function AgentModeControlView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
|
||||
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode.id}
|
||||
@@ -373,4 +413,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentControlsMode } from "./mode";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
|
||||
|
||||
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
|
||||
|
||||
const MODES = [
|
||||
PLAN_MODE,
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "full-access", label: "Full Access" },
|
||||
] satisfies AgentMode[];
|
||||
|
||||
describe("resolveAgentControlsMode", () => {
|
||||
it("uses ready mode when no controlled agent controls are provided", () => {
|
||||
@@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => {
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextAgentModeId", () => {
|
||||
it("cycles from the selected mode to the next mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps from the last mode to the first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
|
||||
});
|
||||
|
||||
it("treats a stale selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
|
||||
"build",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there are fewer than two modes", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
|
||||
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function resolveNextAgentModeId({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
}: {
|
||||
modeOptions: readonly AgentMode[];
|
||||
selectedMode: string | null | undefined;
|
||||
}): string | null {
|
||||
if (modeOptions.length < 2) return null;
|
||||
|
||||
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
|
||||
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = (currentIndex + 1) % modeOptions.length;
|
||||
return modeOptions[nextIndex]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
|
||||
return agentControls ? "draft" : "ready";
|
||||
|
||||
@@ -89,6 +89,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -1746,8 +1747,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, serverId],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1860,90 +1868,92 @@ export function Composer({
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
31
packages/app/src/composer/keyboard-scope.tsx
Normal file
31
packages/app/src/composer/keyboard-scope.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
|
||||
interface ComposerKeyboardScopeValue {
|
||||
isActiveComposer: boolean;
|
||||
}
|
||||
|
||||
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
|
||||
isActiveComposer: false,
|
||||
});
|
||||
|
||||
export function ComposerKeyboardScopeProvider({
|
||||
isActiveComposer,
|
||||
children,
|
||||
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
|
||||
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
|
||||
return (
|
||||
<ComposerKeyboardScopeContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerKeyboardScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerKeyboardScope(): ComposerKeyboardScopeValue {
|
||||
return useContext(ComposerKeyboardScopeContext);
|
||||
}
|
||||
@@ -81,6 +81,39 @@ describe("create agent preferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not erase a saved mode when a later partial update has no mode", () => {
|
||||
expect(
|
||||
mergeProviderPreferences({
|
||||
preferences: {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.5",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
model: "gpt-5.6",
|
||||
mode: undefined,
|
||||
thinkingByModel: undefined,
|
||||
featureValues: undefined,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.6",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads invalid stored preferences as empty preferences", () => {
|
||||
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,43 @@ export function parseFormPreferences(value: unknown): FormPreferences {
|
||||
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
|
||||
}
|
||||
|
||||
function mergeDefinedRecord<T>(
|
||||
existing: Record<string, T> | undefined,
|
||||
updates: Record<string, T> | undefined,
|
||||
): Record<string, T> | undefined {
|
||||
if (updates === undefined) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
|
||||
function applyProviderPreferenceUpdates(
|
||||
existing: ProviderPreferences,
|
||||
updates: Partial<ProviderPreferences>,
|
||||
): ProviderPreferences {
|
||||
const next: ProviderPreferences = { ...existing };
|
||||
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
|
||||
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
|
||||
|
||||
if (updates.model !== undefined) {
|
||||
next.model = updates.model;
|
||||
}
|
||||
if (updates.mode !== undefined) {
|
||||
next.mode = updates.mode;
|
||||
}
|
||||
if (nextThinkingByModel !== undefined) {
|
||||
next.thinkingByModel = nextThinkingByModel;
|
||||
}
|
||||
if (nextFeatureValues !== undefined) {
|
||||
next.featureValues = nextFeatureValues;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeProviderPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
@@ -54,32 +91,13 @@ export function mergeProviderPreferences(args: {
|
||||
const { preferences, provider, updates } = args;
|
||||
const existingProviderPreferences = preferences.providerPreferences ?? {};
|
||||
const existing = existingProviderPreferences[provider] ?? {};
|
||||
const nextThinkingByModel =
|
||||
updates.thinkingByModel === undefined
|
||||
? existing.thinkingByModel
|
||||
: {
|
||||
...existing.thinkingByModel,
|
||||
...updates.thinkingByModel,
|
||||
};
|
||||
const nextFeatureValues =
|
||||
updates.featureValues === undefined
|
||||
? existing.featureValues
|
||||
: {
|
||||
...existing.featureValues,
|
||||
...updates.featureValues,
|
||||
};
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
provider,
|
||||
providerPreferences: {
|
||||
...existingProviderPreferences,
|
||||
[provider]: {
|
||||
...existing,
|
||||
...updates,
|
||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
||||
},
|
||||
[provider]: applyProviderPreferenceUpdates(existing, updates),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.109.0",
|
||||
version: "2.109.3",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.0", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.3", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.7",
|
||||
version: "0.2.9",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.7", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.9", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.1",
|
||||
version: "0.4.7",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.1", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.7", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.153.1",
|
||||
version: "0.157.1",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.153.1", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.157.1", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.18",
|
||||
version: "1.1.19",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.18", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.19", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.24",
|
||||
version: "1.0.26",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.24", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.26", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.18.4",
|
||||
version: "0.19.1",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.4", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.1", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -122,6 +122,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
textMetricsStyle: TextStyle;
|
||||
wrapLines?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -140,14 +141,37 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
|
||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||
}
|
||||
|
||||
function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined {
|
||||
const { lineHeight } = textMetricsStyle;
|
||||
return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined;
|
||||
}
|
||||
|
||||
function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyle> {
|
||||
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
||||
return useMemo(
|
||||
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
|
||||
[lineHeight],
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
|
||||
function HighlightedText({
|
||||
tokens,
|
||||
textMetricsStyle,
|
||||
wrapLines = false,
|
||||
testID,
|
||||
}: HighlightedTextProps) {
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
|
||||
[wrapLines],
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
],
|
||||
[textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
const keyedTokens = useMemo(
|
||||
@@ -246,6 +270,7 @@ function DiffGutterCell({
|
||||
lineNumber,
|
||||
type,
|
||||
gutterWidth,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
isLineHovered,
|
||||
@@ -256,6 +281,7 @@ function DiffGutterCell({
|
||||
lineNumber: number | null;
|
||||
type: DiffLine["type"] | undefined | null;
|
||||
gutterWidth: number;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
isLineHovered?: boolean;
|
||||
@@ -263,23 +289,27 @@ function DiffGutterCell({
|
||||
textTestID?: string;
|
||||
actionTestID?: string;
|
||||
}) {
|
||||
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.gutterCell,
|
||||
lineTypeBackground(type),
|
||||
rowMetricsStyle,
|
||||
inlineUnistylesStyle({ width: gutterWidth }),
|
||||
style,
|
||||
],
|
||||
[type, gutterWidth, style],
|
||||
[type, rowMetricsStyle, gutterWidth, style],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.lineNumberText,
|
||||
type === "add" && styles.addLineNumberText,
|
||||
type === "remove" && styles.removeLineNumberText,
|
||||
],
|
||||
[type],
|
||||
[textMetricsStyle, type],
|
||||
);
|
||||
const comments = useMemo(
|
||||
() =>
|
||||
@@ -297,6 +327,7 @@ function DiffGutterCell({
|
||||
comments={comments}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isLineHovered={isLineHovered}
|
||||
lineHeight={lineHeight}
|
||||
onStartComment={onStartComment}
|
||||
style={containerStyle}
|
||||
actionTestID={actionTestID}
|
||||
@@ -311,6 +342,7 @@ function DiffGutterCell({
|
||||
function DiffTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
@@ -320,6 +352,7 @@ function DiffTextLine({
|
||||
}: {
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
@@ -328,14 +361,16 @@ function DiffTextLine({
|
||||
textTestID?: string;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.textLineContainer, lineTypeBackground(line.type)],
|
||||
[line.type],
|
||||
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
||||
[line.type, rowMetricsStyle],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
@@ -343,7 +378,7 @@ function DiffTextLine({
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
],
|
||||
[line.type, wrapLines],
|
||||
[line.type, textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -356,7 +391,12 @@ function DiffTextLine({
|
||||
style={containerStyle}
|
||||
>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
testID={textTestID}
|
||||
/>
|
||||
) : (
|
||||
<Text style={textStyle} testID={textTestID}>
|
||||
{formatDiffContentText(line.content)}
|
||||
@@ -369,6 +409,7 @@ function DiffTextLine({
|
||||
function SplitTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
@@ -376,20 +417,23 @@ function SplitTextLine({
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
|
||||
[line?.type],
|
||||
() => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
||||
[line?.type, rowMetricsStyle],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line?.type === "add" && styles.addLineText,
|
||||
@@ -397,7 +441,7 @@ function SplitTextLine({
|
||||
line?.type === "context" && styles.contextLineText,
|
||||
!line && styles.emptySplitCellText,
|
||||
],
|
||||
[line, wrapLines],
|
||||
[line, textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -410,7 +454,11 @@ function SplitTextLine({
|
||||
style={containerStyle}
|
||||
>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
@@ -423,6 +471,7 @@ function DiffLineView({
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
}: {
|
||||
@@ -430,19 +479,22 @@ function DiffLineView({
|
||||
lineNumber: number | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
||||
[line.type],
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
||||
[line.type, rowMetricsStyle],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
@@ -450,7 +502,7 @@ function DiffLineView({
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
],
|
||||
[line.type, wrapLines],
|
||||
[line.type, textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -464,13 +516,18 @@ function DiffLineView({
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
||||
)}
|
||||
@@ -482,23 +539,27 @@ function SplitDiffLine({
|
||||
line,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
||||
[line?.type],
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
||||
[line?.type, rowMetricsStyle],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line?.type === "add" && styles.addLineText,
|
||||
@@ -506,7 +567,7 @@ function SplitDiffLine({
|
||||
line?.type === "context" && styles.contextLineText,
|
||||
!line && styles.emptySplitCellText,
|
||||
],
|
||||
[line, wrapLines],
|
||||
[line, textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -520,13 +581,18 @@ function SplitDiffLine({
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
@@ -649,6 +715,7 @@ function SplitDiffColumn({
|
||||
side,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
showDivider = false,
|
||||
}: {
|
||||
@@ -656,6 +723,7 @@ function SplitDiffColumn({
|
||||
side: "left" | "right";
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
@@ -677,6 +745,10 @@ function SplitDiffColumn({
|
||||
],
|
||||
[scrollWidth],
|
||||
);
|
||||
const headerLineTextStyle = useMemo(
|
||||
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
|
||||
[textMetricsStyle],
|
||||
);
|
||||
|
||||
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
|
||||
|
||||
@@ -688,7 +760,7 @@ function SplitDiffColumn({
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={key} style={styles.splitHeaderRow}>
|
||||
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
||||
<Text style={headerLineTextStyle}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -704,6 +776,7 @@ function SplitDiffColumn({
|
||||
line={line}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
<InlineReviewRow
|
||||
@@ -726,7 +799,13 @@ function SplitDiffColumn({
|
||||
{keyedRows.map(({ key, row }) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
|
||||
<DiffGutterCell
|
||||
key={key}
|
||||
lineNumber={null}
|
||||
type="header"
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
@@ -742,6 +821,7 @@ function SplitDiffColumn({
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
@@ -769,7 +849,7 @@ function SplitDiffColumn({
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={key} style={styles.splitHeaderRow}>
|
||||
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
||||
<Text style={headerLineTextStyle}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -785,6 +865,7 @@ function SplitDiffColumn({
|
||||
<SplitTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTargetKey}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
@@ -910,6 +991,7 @@ function DiffFileBody({
|
||||
layout,
|
||||
wrapLines,
|
||||
codeFontSize,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
@@ -918,6 +1000,7 @@ function DiffFileBody({
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
codeFontSize: number;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
|
||||
testID?: string;
|
||||
@@ -978,6 +1061,7 @@ function DiffFileBody({
|
||||
side="left"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
<SplitDiffColumn
|
||||
@@ -985,6 +1069,7 @@ function DiffFileBody({
|
||||
side="right"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
showDivider
|
||||
/>
|
||||
@@ -1005,6 +1090,7 @@ function DiffFileBody({
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
@@ -1031,6 +1117,7 @@ function DiffFileBody({
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
@@ -1059,6 +1146,7 @@ function DiffFileBody({
|
||||
<DiffTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTarget?.key ?? null}
|
||||
@@ -1621,6 +1709,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
|
||||
":",
|
||||
);
|
||||
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
|
||||
const monoFontFamily = appSettings.monoFontFamily.trim();
|
||||
return {
|
||||
fontSize: codeFontSize,
|
||||
lineHeight: diffBodyLineHeight,
|
||||
...(monoFontFamily ? { fontFamily: monoFontFamily } : null),
|
||||
};
|
||||
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
|
||||
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
||||
|
||||
const unifiedToggleStyle = useMemo(
|
||||
@@ -2009,6 +2105,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
codeFontSize={codeFontSize}
|
||||
textMetricsStyle={diffTextMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
@@ -2017,6 +2114,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
},
|
||||
[
|
||||
codeFontSize,
|
||||
diffTextMetricsStyle,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
@@ -2680,7 +2778,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
|
||||
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
|
||||
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
|
||||
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
|
||||
|
||||
export function useSkeletonPulse(): Animated.Value {
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [pulse]);
|
||||
return pulse;
|
||||
}
|
||||
|
||||
export function SkeletonPulse({
|
||||
pulse,
|
||||
style,
|
||||
}: {
|
||||
pulse: Animated.Value;
|
||||
style: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const opacity = pulse.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.4, 0.8],
|
||||
});
|
||||
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
|
||||
return <Animated.View style={pulseStyle} />;
|
||||
}
|
||||
|
||||
export function PrActivitySkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="pr-pane-activity-skeleton">
|
||||
{ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.row}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.avatar} />
|
||||
<View style={styles.lines}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineWide} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineNarrow} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lines: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
lineWide: {
|
||||
width: "70%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lineNarrow: {
|
||||
width: "45%",
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,6 @@
|
||||
export { PullRequestPane } from "./pane";
|
||||
export { PullRequestPaneError } from "./pane-error";
|
||||
export { PullRequestPaneSkeleton } from "./pane-skeleton";
|
||||
export { PullRequestTabIcon } from "./tab-icon";
|
||||
export { formatPrTabLabel } from "./tab-label";
|
||||
export { prPaneTimelineQueryKind } from "./query-keys";
|
||||
|
||||
39
packages/app/src/git/pull-request-panel/pane-error.tsx
Normal file
39
packages/app/src/git/pull-request-panel/pane-error.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PullRequestPaneError({
|
||||
onRetry,
|
||||
message,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-error">
|
||||
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
|
||||
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
113
packages/app/src/git/pull-request-panel/pane-skeleton.tsx
Normal file
113
packages/app/src/git/pull-request-panel/pane-skeleton.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
|
||||
|
||||
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
|
||||
|
||||
export function PullRequestPaneSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-skeleton">
|
||||
<View style={styles.header}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.title} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
|
||||
</View>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||
<View style={styles.checks}>
|
||||
{CHECK_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.checkRow}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkDot} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkName} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<PrActivitySkeleton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
header: {
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
title: {
|
||||
width: "75%",
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
subtitle: {
|
||||
width: "40%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
toolbarButton: {
|
||||
width: 96,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
section: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
checks: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
checkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
minHeight: 32,
|
||||
},
|
||||
checkDot: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
checkName: {
|
||||
width: "60%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -42,9 +45,12 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { PrActivitySkeleton } from "./activity-skeleton";
|
||||
import {
|
||||
collapseActivity,
|
||||
expandActivity,
|
||||
@@ -85,6 +91,8 @@ const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft);
|
||||
const ThemedMessageSquare = withUnistyles(MessageSquare);
|
||||
const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus);
|
||||
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -138,6 +146,13 @@ function kebabTriggerStyle({
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function refreshButtonStyle({
|
||||
hovered = false,
|
||||
pressed = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreHorizontal
|
||||
@@ -176,13 +191,17 @@ export function PullRequestPane({
|
||||
serverId,
|
||||
cwd,
|
||||
data,
|
||||
activityLoading,
|
||||
workspaceAttachmentScopeKey,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
data: PrPaneData;
|
||||
activityLoading: boolean;
|
||||
workspaceAttachmentScopeKey?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const canFetchGitHubCheckDetails = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
||||
@@ -199,6 +218,24 @@ export function PullRequestPane({
|
||||
void openExternalUrl(data.url);
|
||||
}, [data.url]);
|
||||
|
||||
const refreshSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "refresh" }),
|
||||
) === "pending";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
return;
|
||||
}
|
||||
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
|
||||
const handleToggleChecks = useCallback(() => {
|
||||
setChecksOpen((open) => !open);
|
||||
}, []);
|
||||
@@ -395,6 +432,47 @@ export function PullRequestPane({
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
leftIcon={ExternalLink}
|
||||
onPress={handleOpenPrUrl}
|
||||
style={styles.viewButton}
|
||||
testID="pr-pane-view-pr"
|
||||
>
|
||||
{t("workspace.git.pr.actions.viewPullRequest")}
|
||||
</Button>
|
||||
</View>
|
||||
{refreshSupported ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState")
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={refreshButtonStyle}
|
||||
hitSlop={8}
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshing ? (
|
||||
<ThemedLoadingSpinner
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -420,8 +498,6 @@ export function PullRequestPane({
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Checks"
|
||||
open={checksOpen}
|
||||
@@ -488,27 +564,30 @@ export function PullRequestPane({
|
||||
size="xs"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
Add all to chat
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{visibleEntries.length === 0 ? (
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No activity yet</Text>
|
||||
) : (
|
||||
visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -1236,6 +1315,48 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3],
|
||||
},
|
||||
toolbarActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
viewButton: {
|
||||
gap: theme.spacing[1],
|
||||
minHeight: 24,
|
||||
height: 24,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
refreshButton: {
|
||||
marginLeft: "auto",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
refreshButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
refreshIcon: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -435,6 +435,37 @@ describe("selectPrPaneState", () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports activityLoading while the timeline is pending its first response", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report activityLoading when no timeline fetch was scheduled", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: false,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report activityLoading once the timeline payload resolves", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: false,
|
||||
timelinePayload: timelinePayload(),
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports refreshing during background revalidation of either query", () => {
|
||||
expect(
|
||||
selectPrPaneState({
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface UsePrPaneDataResult {
|
||||
data: PrPaneData | null;
|
||||
prNumber: number | null;
|
||||
isLoading: boolean;
|
||||
activityLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: Error | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -167,13 +168,14 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
|
||||
: mapPrPaneData(input.status, input.timelinePayload);
|
||||
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
|
||||
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
|
||||
const timelinePending =
|
||||
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber: identity.prNumber,
|
||||
isLoading:
|
||||
input.statusIsLoading ||
|
||||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
|
||||
isLoading: input.statusIsLoading || timelinePending,
|
||||
activityLoading: timelinePending,
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: input.statusPayloadError,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
|
||||
import {
|
||||
buildGitActions,
|
||||
narrowPullRequestState,
|
||||
type BuildGitActionsInput,
|
||||
type GitAction,
|
||||
type GitActions,
|
||||
} from "@/git/policy";
|
||||
@@ -568,8 +569,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
}, [prStatus?.url, handleCreatePr]);
|
||||
|
||||
// Build actions
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
const actions = buildGitActions({
|
||||
const gitActionsInput = useMemo<BuildGitActionsInput>(
|
||||
() => ({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
@@ -684,61 +685,66 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t });
|
||||
}, [
|
||||
t,
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
]);
|
||||
}),
|
||||
[
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
);
|
||||
|
||||
const gitActions: GitActions = useMemo(
|
||||
() =>
|
||||
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
|
||||
[gitActionsInput, baseRefLabel, hasPullRequest, t],
|
||||
);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ describe("translation resources", () => {
|
||||
expect(en.composer.github.title).toBe("Attach issue or PR");
|
||||
expect(en.agentControls.provider.fallback).toBe("Provider");
|
||||
expect(en.agentControls.hints.model).toBe("Change model");
|
||||
expect(en.agentControls.hints.mode).toBe("Change mode");
|
||||
expect(en.agentControls.features.title).toBe("Features");
|
||||
expect(en.agentControls.mode.title).toBe("Mode");
|
||||
expect(en.agentStream.permission.required).toBe("Permission Required");
|
||||
@@ -243,6 +244,7 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.git.actions.commit.label).toBe("Commit");
|
||||
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
|
||||
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
|
||||
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
|
||||
expect(en.review.comment.placeholder).toBe("Leave a comment");
|
||||
});
|
||||
|
||||
@@ -288,6 +290,7 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
|
||||
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
|
||||
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
|
||||
expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode");
|
||||
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
|
||||
"Available when focus is not in a text field or terminal.",
|
||||
);
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "وضع التفكير",
|
||||
model: "تغيير النموذج",
|
||||
mode: "تغيير وضع الإذن",
|
||||
mode: "تغيير الوضع",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -717,6 +717,9 @@ export const ar: TranslationResources = {
|
||||
failedOpen: "فشل في فتح مساحة العمل",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "عرض",
|
||||
},
|
||||
sections: {
|
||||
checks: "الشيكات",
|
||||
reviews: "التعليقات",
|
||||
@@ -1564,6 +1567,7 @@ export const ar: TranslationResources = {
|
||||
toggleFocusMode: "تبديل وضع التركيز",
|
||||
cycleTheme: "موضوع الدورة",
|
||||
focusMessageInput: "التركيز على إدخال الرسالة",
|
||||
cycleAgentMode: "تبديل وضع الوكيل",
|
||||
toggleVoiceMode: "تبديل الوضع الصوتي",
|
||||
startStopDictation: "بدء إملاء /stop",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
|
||||
@@ -163,7 +163,7 @@ export const en = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "Change model",
|
||||
mode: "Change permission mode",
|
||||
mode: "Change mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -724,6 +724,9 @@ export const en = {
|
||||
failedOpen: "Failed to open workspace",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "View",
|
||||
},
|
||||
sections: {
|
||||
checks: "Checks",
|
||||
reviews: "Reviews",
|
||||
@@ -1570,6 +1573,7 @@ export const en = {
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
cycleTheme: "Cycle theme",
|
||||
focusMessageInput: "Focus message input",
|
||||
cycleAgentMode: "Cycle agent mode",
|
||||
toggleVoiceMode: "Toggle voice mode",
|
||||
startStopDictation: "Start/stop dictation",
|
||||
interruptAgent: "Interrupt agent",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const es: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Modo de pensamiento",
|
||||
model: "Cambiar modelo",
|
||||
mode: "Cambiar modo de permiso",
|
||||
mode: "Cambiar modo",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -744,6 +744,9 @@ export const es: TranslationResources = {
|
||||
failedOpen: "No se pudo abrir el espacio de trabajo",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Ver",
|
||||
},
|
||||
sections: {
|
||||
checks: "cheques",
|
||||
reviews: "Reseñas",
|
||||
@@ -1602,6 +1605,7 @@ export const es: TranslationResources = {
|
||||
toggleFocusMode: "Alternar modo de enfoque",
|
||||
cycleTheme: "Tema del ciclo",
|
||||
focusMessageInput: "Entrada de mensaje de enfoque",
|
||||
cycleAgentMode: "Alternar modo del agente",
|
||||
toggleVoiceMode: "Alternar modo de voz",
|
||||
startStopDictation: "Iniciar dictado/stop",
|
||||
interruptAgent: "agente de interrupción",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Mode réflexion",
|
||||
model: "Changer de modèle",
|
||||
mode: "Changer le mode d'autorisation",
|
||||
mode: "Changer de mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -743,6 +743,9 @@ export const fr: TranslationResources = {
|
||||
failedOpen: "Échec de l'ouverture de l'espace de travail",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Voir",
|
||||
},
|
||||
sections: {
|
||||
checks: "Chèques",
|
||||
reviews: "Avis",
|
||||
@@ -1606,6 +1609,7 @@ export const fr: TranslationResources = {
|
||||
toggleFocusMode: "Basculer le mode de mise au point",
|
||||
cycleTheme: "Thème du cycle",
|
||||
focusMessageInput: "Saisie du message de focus",
|
||||
cycleAgentMode: "Parcourir les modes de l'agent",
|
||||
toggleVoiceMode: "Changer le mode vocal",
|
||||
startStopDictation: "Démarrer la dictée/stop",
|
||||
interruptAgent: "Agent d'interruption",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ru: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Режим мышления",
|
||||
model: "Изменить модель",
|
||||
mode: "Изменить режим разрешений",
|
||||
mode: "Изменить режим",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -736,6 +736,9 @@ export const ru: TranslationResources = {
|
||||
failedOpen: "Не удалось открыть рабочую область",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Открыть",
|
||||
},
|
||||
sections: {
|
||||
checks: "Чеки",
|
||||
reviews: "Отзывы",
|
||||
@@ -1594,6 +1597,7 @@ export const ru: TranslationResources = {
|
||||
toggleFocusMode: "Переключить режим фокусировки",
|
||||
cycleTheme: "Циклическая тема",
|
||||
focusMessageInput: "Фокус ввода сообщения",
|
||||
cycleAgentMode: "Переключить режим агента",
|
||||
toggleVoiceMode: "Переключить голосовой режим",
|
||||
startStopDictation: "Начать диктовку /stop",
|
||||
interruptAgent: "Агент прерываний",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const zhCN: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "切换 Model",
|
||||
mode: "切换权限 Mode",
|
||||
mode: "更改模式",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -711,6 +711,9 @@ export const zhCN: TranslationResources = {
|
||||
failedOpen: "打开 workspace 失败",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "查看",
|
||||
},
|
||||
sections: {
|
||||
checks: "Checks",
|
||||
reviews: "Reviews",
|
||||
@@ -1545,6 +1548,7 @@ export const zhCN: TranslationResources = {
|
||||
toggleFocusMode: "切换专注模式",
|
||||
cycleTheme: "循环切换主题",
|
||||
focusMessageInput: "聚焦消息输入框",
|
||||
cycleAgentMode: "循环切换代理模式",
|
||||
toggleVoiceMode: "切换语音模式",
|
||||
startStopDictation: "开始/停止听写",
|
||||
interruptAgent: "中断 Agent",
|
||||
|
||||
@@ -12,7 +12,8 @@ export type MessageInputKeyboardActionKind =
|
||||
| "dictation-cancel"
|
||||
| "dictation-confirm"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
| "voice-mute-toggle"
|
||||
| "mode-cycle";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.interrupt"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type KeyboardActionId =
|
||||
| "message-input.dictation-confirm"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "message-input.mode-cycle"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close-current"
|
||||
| "workspace.tab.navigate-index"
|
||||
@@ -39,6 +40,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.mode-cycle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||
|
||||
@@ -299,6 +299,13 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "message-input.action",
|
||||
payload: { kind: "dictation-toggle" },
|
||||
},
|
||||
{
|
||||
name: "routes Shift+Tab to cycle agent mode from the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "mode-cycle" },
|
||||
},
|
||||
{
|
||||
name: "routes space to voice mute toggle outside editable scopes",
|
||||
event: { key: " ", code: "Space" },
|
||||
@@ -427,6 +434,16 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not cycle agent mode outside the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "other" },
|
||||
},
|
||||
{
|
||||
name: "does not repeat agent mode cycling while Shift+Tab is held",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true, repeat: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not bind Cmd+Enter as a rebindable message queue shortcut",
|
||||
event: { key: "Enter", code: "Enter", metaKey: true },
|
||||
@@ -570,6 +587,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
"cycle-agent-mode": ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -617,12 +635,14 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const projects = sections.find((section) => section.id === "projects");
|
||||
const panels = sections.find((section) => section.id === "panels");
|
||||
const openProject = findRow(sections, "new-agent");
|
||||
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
|
||||
const showShortcuts = findRow(sections, "show-shortcuts");
|
||||
|
||||
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
|
||||
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
|
||||
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
|
||||
expect(openProject?.label).toBe("Open project");
|
||||
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
|
||||
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
||||
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
||||
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
||||
"cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode",
|
||||
"voice-toggle": "settings.shortcuts.help.toggleVoiceMode",
|
||||
"dictation-toggle": "settings.shortcuts.help.startStopDictation",
|
||||
"agent-interrupt": "settings.shortcuts.help.interruptAgent",
|
||||
@@ -910,6 +911,20 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-mode-cycle-shift-tab",
|
||||
action: "message-input.action",
|
||||
combo: "Shift+Tab",
|
||||
repeat: false,
|
||||
when: { commandCenter: false, focusScope: "message-input" },
|
||||
payload: { type: "message-input", kind: "mode-cycle" },
|
||||
help: {
|
||||
id: "cycle-agent-mode",
|
||||
section: "agent-input",
|
||||
label: "Cycle agent mode",
|
||||
keys: ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||
action: "message-input.action",
|
||||
|
||||
@@ -276,6 +276,7 @@ describe("routeKeyboardShortcut — message-input.action", () => {
|
||||
["dictation-confirm", "message-input.dictation-confirm"],
|
||||
["voice-toggle", "message-input.voice-toggle"],
|
||||
["voice-mute-toggle", "message-input.voice-mute-toggle"],
|
||||
["mode-cycle", "message-input.mode-cycle"],
|
||||
] as const)("kind=%s → dispatch %s", (kind, id) => {
|
||||
expect(
|
||||
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
|
||||
|
||||
@@ -82,6 +82,7 @@ const MESSAGE_INPUT_DISPATCH: Record<
|
||||
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
|
||||
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
|
||||
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
|
||||
"mode-cycle": { id: "message-input.mode-cycle", scope: "message-input" },
|
||||
};
|
||||
|
||||
function hasPayloadKey<K extends "index" | "delta" | "kind">(
|
||||
|
||||
21
packages/app/src/navigation/host-route-context.tsx
Normal file
21
packages/app/src/navigation/host-route-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
const HostRouteServerIdContext = createContext<string | null>(null);
|
||||
|
||||
export function HostRouteProvider({
|
||||
children,
|
||||
serverId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
serverId: string;
|
||||
}) {
|
||||
return (
|
||||
<HostRouteServerIdContext.Provider value={serverId}>
|
||||
{children}
|
||||
</HostRouteServerIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostRouteServerId(): string | null {
|
||||
return useContext(HostRouteServerIdContext);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
resolveStartupRoute,
|
||||
@@ -200,6 +201,7 @@ describe("resolveStartupRoute", () => {
|
||||
hosts: [],
|
||||
anyOnlineHostServerId: null,
|
||||
workspaceSelection: null,
|
||||
workspaceSelectionStatus: "unknown" as const,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
};
|
||||
@@ -255,10 +257,22 @@ describe("resolveStartupRoute", () => {
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
|
||||
});
|
||||
|
||||
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "missing",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
||||
});
|
||||
|
||||
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
@@ -344,3 +358,45 @@ describe("resolveStartupRoute", () => {
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostIndexRoute", () => {
|
||||
it("restores the remembered workspace when the host index is reopened for the same host", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual("/h/server-saved/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "unknown",
|
||||
}),
|
||||
).toEqual("/h/server-saved/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("opens project selection when the remembered workspace is proven missing", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "missing",
|
||||
}),
|
||||
).toEqual("/h/server-saved/open-project");
|
||||
});
|
||||
|
||||
it("opens project selection when the remembered workspace belongs to another host", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual("/h/server-saved/open-project");
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,7 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
|
||||
route: IndexStartupRouteTarget;
|
||||
anyOnlineHostServerId: string | null;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
isWorkspaceSelectionLoaded: boolean;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}
|
||||
@@ -151,6 +152,42 @@ export type StartupRouteDecision =
|
||||
| { kind: "splash" }
|
||||
| { kind: "redirect"; href: Href };
|
||||
|
||||
export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
|
||||
|
||||
function shouldRestoreWorkspaceSelection(input: {
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
}): input is {
|
||||
workspaceSelection: ActiveWorkspaceSelection;
|
||||
workspaceSelectionStatus: Exclude<WorkspaceSelectionStatus, "missing">;
|
||||
} {
|
||||
return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
|
||||
}
|
||||
|
||||
export function resolveWorkspaceSelectionStatus(input: {
|
||||
hasHydratedWorkspaces: boolean;
|
||||
workspaceExists: boolean;
|
||||
}): WorkspaceSelectionStatus {
|
||||
if (input.workspaceExists) {
|
||||
return "exists";
|
||||
}
|
||||
return input.hasHydratedWorkspaces ? "missing" : "unknown";
|
||||
}
|
||||
|
||||
export function resolveHostIndexRoute(input: {
|
||||
serverId: string;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
}): Href {
|
||||
if (
|
||||
input.workspaceSelection?.serverId === input.serverId &&
|
||||
shouldRestoreWorkspaceSelection(input)
|
||||
) {
|
||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
|
||||
}
|
||||
return buildHostOpenProjectRoute(input.serverId);
|
||||
}
|
||||
|
||||
function isIndexPathname(pathname: string) {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
@@ -171,11 +208,16 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
const workspaceSelection = input.workspaceSelection;
|
||||
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
|
||||
if (
|
||||
shouldRestoreWorkspaceSelection(input) &&
|
||||
hostExists(input.hosts, input.workspaceSelection.serverId)
|
||||
) {
|
||||
return {
|
||||
kind: "redirect",
|
||||
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
|
||||
href: buildHostWorkspaceRoute(
|
||||
input.workspaceSelection.serverId,
|
||||
input.workspaceSelection.workspaceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,6 +511,59 @@ describe("resolveFormState", () => {
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("preserves the saved mode while provider modes are absent from a loading snapshot", () => {
|
||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: loadingEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: { codex: { mode: "full-access", model: "gpt-5.3-codex" } },
|
||||
},
|
||||
null,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" }).form,
|
||||
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("full-access");
|
||||
});
|
||||
|
||||
it("preserves a saved mode that is not in the current mode list", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: { codex: { mode: "workspace-write", model: "gpt-5.3-codex" } },
|
||||
},
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("workspace-write");
|
||||
});
|
||||
|
||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
|
||||
@@ -144,6 +144,24 @@ export function resolveThinkingOptionId(args: {
|
||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||
}
|
||||
|
||||
const normalizeSelectedModeId = normalizeSelectedModelId;
|
||||
|
||||
function resolvePreferredModeId(input: {
|
||||
initialModeId?: string | null;
|
||||
preferredModeId?: string | null;
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
}): string {
|
||||
// Saved modes are user intent. Provider create config validates unknown modes
|
||||
// at submission time, so background form resolution should not erase them.
|
||||
const initialModeId = normalizeSelectedModeId(input.initialModeId);
|
||||
if (initialModeId) return initialModeId;
|
||||
|
||||
const preferredModeId = normalizeSelectedModeId(input.preferredModeId);
|
||||
if (preferredModeId) return preferredModeId;
|
||||
|
||||
return input.providerDef?.defaultModeId ?? input.providerDef?.modes[0]?.id ?? "";
|
||||
}
|
||||
|
||||
export function mergeSelectedComposerPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
@@ -259,18 +277,11 @@ function resolveModeId(input: {
|
||||
input;
|
||||
if (userModified) return currentModeId;
|
||||
if (!provider) return "";
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
) {
|
||||
return initialValues.modeId;
|
||||
}
|
||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||
return providerPrefs.mode;
|
||||
}
|
||||
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||
return resolvePreferredModeId({
|
||||
initialModeId: initialValues?.modeId,
|
||||
preferredModeId: providerPrefs?.mode,
|
||||
providerDef,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelField(input: {
|
||||
@@ -411,11 +422,10 @@ function pickNextModeForProvider(input: {
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { providerDef, providerPrefs } = input;
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||
return providerPrefs.mode;
|
||||
}
|
||||
return providerDef?.defaultModeId ?? "";
|
||||
return resolvePreferredModeId({
|
||||
preferredModeId: providerPrefs?.mode,
|
||||
providerDef,
|
||||
});
|
||||
}
|
||||
|
||||
function pickNextModeForProviderAndModel(input: {
|
||||
@@ -425,14 +435,8 @@ function pickNextModeForProviderAndModel(input: {
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const validModeIds = input.providerDef?.modes.map((m) => m.id) ?? [];
|
||||
if (
|
||||
input.currentProvider === input.provider &&
|
||||
input.currentModeId &&
|
||||
validModeIds.includes(input.currentModeId)
|
||||
) {
|
||||
return input.currentModeId;
|
||||
}
|
||||
const currentModeId = normalizeSelectedModeId(input.currentModeId);
|
||||
if (input.currentProvider === input.provider && currentModeId) return currentModeId;
|
||||
return pickNextModeForProvider({
|
||||
providerDef: input.providerDef,
|
||||
providerPrefs: input.providerPrefs,
|
||||
|
||||
@@ -282,6 +282,7 @@ export function InlineReviewGutterCell({
|
||||
reviewTarget,
|
||||
comments,
|
||||
isLineHovered = false,
|
||||
lineHeight,
|
||||
onStartComment,
|
||||
style,
|
||||
actionTestID,
|
||||
@@ -291,6 +292,7 @@ export function InlineReviewGutterCell({
|
||||
comments: readonly ReviewDraftComment[];
|
||||
isEditorOpen: boolean;
|
||||
isLineHovered?: boolean;
|
||||
lineHeight?: number;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
actionTestID?: string;
|
||||
@@ -334,10 +336,28 @@ export function InlineReviewGutterCell({
|
||||
}, [isInteractionActive]);
|
||||
|
||||
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
|
||||
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() =>
|
||||
lineHeight !== undefined
|
||||
? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight })
|
||||
: null,
|
||||
[lineHeight],
|
||||
);
|
||||
|
||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
||||
[hasComments],
|
||||
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
|
||||
[hasComments, lineHeightStyle],
|
||||
);
|
||||
const innerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterInner, lineHeightStyle],
|
||||
[lineHeightStyle],
|
||||
);
|
||||
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.gutterActionIcon,
|
||||
lineHeight !== undefined && inlineUnistylesStyle({ top: Math.floor((lineHeight - 22) / 2) }),
|
||||
],
|
||||
[lineHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -353,11 +373,11 @@ export function InlineReviewGutterCell({
|
||||
onPressOut={handlePressOut}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<View style={styles.gutterInner}>
|
||||
<View style={innerStyle}>
|
||||
<View style={labelStyle}>
|
||||
{children}
|
||||
{showAction ? (
|
||||
<View style={styles.gutterActionIcon} testID={actionTestID}>
|
||||
<View style={actionIconStyle} testID={actionTestID}>
|
||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
|
||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseWorkspaceCheckoutStatusInput {
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
export function useWorkspaceCheckoutStatus(input: UseWorkspaceCheckoutStatusInput) {
|
||||
const { t } = useTranslation();
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() =>
|
||||
canCreateWorkspaceTerminal({
|
||||
isRouteFocused: input.isRouteFocused,
|
||||
client: input.client,
|
||||
isConnected: input.isConnected,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
}),
|
||||
[input.client, input.isConnected, input.isRouteFocused, input.workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
input.normalizedServerId,
|
||||
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!input.client || !input.workspaceDirectory) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await fetchCheckoutStatus({
|
||||
client: input.client,
|
||||
serverId: input.normalizedServerId,
|
||||
cwd: input.workspaceDirectory,
|
||||
});
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[checkoutQuery.data, checkoutQuery.isError, isCheckoutQueryEnabled],
|
||||
);
|
||||
|
||||
return { checkoutQuery, isCheckoutStatusLoading };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
interface OpenWorkspaceFileFromExplorerInput {
|
||||
filePath: string;
|
||||
persistenceKey: string | null;
|
||||
showMobileAgent: () => void;
|
||||
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
|
||||
}
|
||||
|
||||
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
|
||||
input.showMobileAgent();
|
||||
if (!input.persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const tabId = input.openWorkspaceTabFocused(
|
||||
input.persistenceKey,
|
||||
createWorkspaceFileTabTarget(location),
|
||||
);
|
||||
if (tabId) {
|
||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -66,9 +65,7 @@ import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
@@ -105,8 +102,6 @@ import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/works
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -178,7 +173,6 @@ import {
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||
import {
|
||||
useWorkspaceTerminals,
|
||||
type TerminalProfileInput,
|
||||
@@ -196,6 +190,7 @@ import {
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||
@@ -203,7 +198,6 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
function getWorkspaceScripts(
|
||||
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
|
||||
@@ -868,29 +862,6 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
);
|
||||
});
|
||||
|
||||
interface MobileExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
onOpenExplorer: () => void;
|
||||
}
|
||||
|
||||
function MobileExplorerOpenGestureSurface({
|
||||
children,
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: MobileExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View style={styles.content}>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
||||
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
|
||||
const tabDescriptorMap = useMemo(() => {
|
||||
@@ -923,16 +894,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
|
||||
|
||||
return (
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<WorkspaceScreenContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={effectiveRouteFocused}
|
||||
/>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
<WorkspaceScreenContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1594,7 +1561,7 @@ function shouldShowWorkspaceExplorerSidebar(input: {
|
||||
isFocusModeEnabled: boolean;
|
||||
isMobile: boolean;
|
||||
}): boolean {
|
||||
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
@@ -1676,56 +1643,6 @@ function useWorkspaceTerminalTabActions({
|
||||
};
|
||||
}
|
||||
|
||||
function useWorkspaceCheckoutStatus(input: {
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() =>
|
||||
canCreateWorkspaceTerminal({
|
||||
isRouteFocused: input.isRouteFocused,
|
||||
client: input.client,
|
||||
isConnected: input.isConnected,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
}),
|
||||
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
input.normalizedServerId,
|
||||
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!input.client || !input.workspaceDirectory) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await fetchCheckoutStatus({
|
||||
client: input.client,
|
||||
serverId: input.normalizedServerId,
|
||||
cwd: input.workspaceDirectory,
|
||||
});
|
||||
},
|
||||
staleTime: Infinity,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||
);
|
||||
|
||||
return { checkoutQuery, isCheckoutStatusLoading };
|
||||
}
|
||||
|
||||
function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -1890,7 +1807,6 @@ function WorkspaceScreenContent({
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const toggleFileExplorerForCheckout = usePanelStore(
|
||||
(state) => state.toggleFileExplorerForCheckout,
|
||||
);
|
||||
@@ -1907,16 +1823,6 @@ function WorkspaceScreenContent({
|
||||
};
|
||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
const openExplorerForWorkspace = useCallback(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout: activeExplorerCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
|
||||
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
@@ -1964,7 +1870,7 @@ function WorkspaceScreenContent({
|
||||
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
|
||||
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
|
||||
);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
|
||||
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
|
||||
const uiTabs = useMemo(
|
||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||
@@ -2176,54 +2082,22 @@ function WorkspaceScreenContent({
|
||||
|
||||
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
||||
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
|
||||
const requestedWorkspaceSetupStatusKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) {
|
||||
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
if (!client || !normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (workspaceSetupSnapshot) {
|
||||
return;
|
||||
}
|
||||
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestedWorkspaceSetupStatusKeyRef.current = persistenceKey;
|
||||
let isCancelled = false;
|
||||
|
||||
client
|
||||
.fetchWorkspaceSetupStatus(normalizedWorkspaceId)
|
||||
.then((response) => {
|
||||
if (isCancelled || response.workspaceId !== normalizedWorkspaceId || !response.snapshot) {
|
||||
return;
|
||||
}
|
||||
upsertWorkspaceSetupProgress({
|
||||
serverId: normalizedServerId,
|
||||
payload: { workspaceId: response.workspaceId, ...response.snapshot },
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
|
||||
requestedWorkspaceSetupStatusKeyRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
ensureWorkspaceSetupStatus({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
client,
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
ensureWorkspaceSetupStatus,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
persistenceKey,
|
||||
upsertWorkspaceSetupProgress,
|
||||
workspaceSetupSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2319,9 +2193,6 @@ function WorkspaceScreenContent({
|
||||
|
||||
const handleOpenFileFromExplorer = useCallback(
|
||||
function handleOpenFileFromExplorer(filePath: string) {
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
}
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
@@ -2334,7 +2205,7 @@ function WorkspaceScreenContent({
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
|
||||
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
@@ -3727,12 +3598,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<MobileExplorerOpenGestureSurface
|
||||
enabled={Boolean(activeExplorerCheckout)}
|
||||
onOpenExplorer={openExplorerForWorkspace}
|
||||
>
|
||||
{content}
|
||||
</MobileExplorerOpenGestureSurface>
|
||||
<View style={styles.content}>{content}</View>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,111 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "./workspace-setup-store";
|
||||
import {
|
||||
shouldShowWorkspaceSetup,
|
||||
useWorkspaceSetupStore,
|
||||
type WorkspaceSetupStatusClient,
|
||||
type WorkspaceSetupStatusResult,
|
||||
} from "./workspace-setup-store";
|
||||
|
||||
const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = {
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
function setupResult(
|
||||
workspaceId: string,
|
||||
snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT,
|
||||
): WorkspaceSetupStatusResult {
|
||||
return { requestId: "req-1", workspaceId, snapshot };
|
||||
}
|
||||
|
||||
function makeClient(handler: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>) {
|
||||
const calls: string[] = [];
|
||||
const client: WorkspaceSetupStatusClient = {
|
||||
fetchWorkspaceSetupStatus: (workspaceId) => {
|
||||
calls.push(workspaceId);
|
||||
return handler(workspaceId);
|
||||
},
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function storedSnapshots() {
|
||||
return Object.values(useWorkspaceSetupStore.getState().snapshots);
|
||||
}
|
||||
|
||||
function resolveDefault(workspaceId: string): Promise<WorkspaceSetupStatusResult> {
|
||||
return Promise.resolve(setupResult(workspaceId));
|
||||
}
|
||||
|
||||
function rejectThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function nullThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(setupResult(workspaceId, null));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function mismatchThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(setupResult("999"));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSetupStatus(client: WorkspaceSetupStatusClient) {
|
||||
useWorkspaceSetupStore.getState().ensureSetupStatus({
|
||||
serverId: "server-1",
|
||||
workspaceId: "42",
|
||||
client,
|
||||
});
|
||||
}
|
||||
|
||||
describe("workspace-setup-store", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
|
||||
useWorkspaceSetupStore.setState({
|
||||
pendingWorkspaceSetup: null,
|
||||
snapshots: {},
|
||||
requestedKeys: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks deferred workspace setup by source directory and optional workspace id", () => {
|
||||
@@ -96,4 +198,118 @@ describe("workspace-setup-store", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => {
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toEqual([
|
||||
expect.objectContaining({ workspaceId: "42", status: "running" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus does not refetch while a request is in flight", async () => {
|
||||
const deferred = createDeferred<WorkspaceSetupStatusResult>();
|
||||
const { client, calls } = makeClient(() => deferred.promise);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
ensureSetupStatus(client);
|
||||
|
||||
expect(calls).toEqual(["42"]);
|
||||
|
||||
deferred.resolve(setupResult("42"));
|
||||
await flush();
|
||||
|
||||
ensureSetupStatus(client);
|
||||
expect(calls).toEqual(["42"]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus skips fetching when a snapshot already exists", () => {
|
||||
useWorkspaceSetupStore.getState().upsertProgress({
|
||||
serverId: "server-1",
|
||||
payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT },
|
||||
});
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus ignores a response for a different workspace", async () => {
|
||||
const { client } = makeClient(() => Promise.resolve(setupResult("999")));
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => {
|
||||
const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null)));
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after a null-snapshot response", async () => {
|
||||
const { client, calls } = makeClient(nullThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after a mismatched-workspace response", async () => {
|
||||
const { client, calls } = makeClient(mismatchThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => {
|
||||
const { client, calls } = makeClient(rejectThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after the workspace is removed", async () => {
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" });
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,15 @@ export type WorkspaceSetupProgressPayload = Extract<
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export type WorkspaceSetupStatusResult = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_status_response" }
|
||||
>["payload"];
|
||||
|
||||
export interface WorkspaceSetupStatusClient {
|
||||
fetchWorkspaceSetupStatus: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>;
|
||||
}
|
||||
|
||||
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -31,9 +40,15 @@ export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null
|
||||
interface WorkspaceSetupStoreState {
|
||||
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
|
||||
snapshots: Record<string, WorkspaceSetupSnapshot>;
|
||||
requestedKeys: Set<string>;
|
||||
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
|
||||
clearWorkspaceSetup: () => void;
|
||||
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
|
||||
ensureSetupStatus: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
client: WorkspaceSetupStatusClient;
|
||||
}) => void;
|
||||
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
clearServer: (serverId: string) => void;
|
||||
}
|
||||
@@ -42,9 +57,10 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }
|
||||
return buildWorkspaceTabPersistenceKey(input);
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, get) => ({
|
||||
pendingWorkspaceSetup: null,
|
||||
snapshots: {},
|
||||
requestedKeys: new Set(),
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({ pendingWorkspaceSetup: value });
|
||||
},
|
||||
@@ -67,6 +83,40 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) =
|
||||
},
|
||||
}));
|
||||
},
|
||||
ensureSetupStatus: async ({ serverId, workspaceId, client }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const state = get();
|
||||
if (state.snapshots[key] || state.requestedKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is
|
||||
// released once the request settles. A settle that stored no snapshot (null snapshot,
|
||||
// mismatched workspace, or error) leaves no marker, so a later call can retry; once a
|
||||
// snapshot lands, the snapshots[key] guard above prevents redundant refetches.
|
||||
set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) }));
|
||||
|
||||
try {
|
||||
const response = await client.fetchWorkspaceSetupStatus(workspaceId);
|
||||
if (response.workspaceId === workspaceId && response.snapshot) {
|
||||
get().upsertProgress({
|
||||
serverId,
|
||||
payload: { workspaceId: response.workspaceId, ...response.snapshot },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Swallowed: the finally clears the in-flight marker so a later call retries.
|
||||
} finally {
|
||||
set((current) => {
|
||||
const next = new Set(current.requestedKeys);
|
||||
next.delete(key);
|
||||
return { requestedKeys: next };
|
||||
});
|
||||
}
|
||||
},
|
||||
removeWorkspace: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
|
||||
@@ -8,6 +8,12 @@ describe("formatShortcut", () => {
|
||||
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
|
||||
});
|
||||
|
||||
it("spells out Shift in shortcut labels", () => {
|
||||
expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab");
|
||||
expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P");
|
||||
expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab");
|
||||
});
|
||||
|
||||
it("uses Ctrl+ on non-mac platforms", () => {
|
||||
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
|
||||
@@ -27,18 +27,22 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
const order = ["ctrl", "alt", "shift", "mod", "meta"];
|
||||
const symbols: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
ctrl: "⌃",
|
||||
meta: "⌘",
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? "")));
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
.join("");
|
||||
if (mods.includes("Shift")) {
|
||||
return [...mods, main].filter(Boolean).join("+");
|
||||
}
|
||||
return `${mods.join("")}${main}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/server": "0.1.98",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/server": "0.1.100",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/relay": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.98",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -65,10 +65,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.98",
|
||||
"@getpaseo/highlight": "0.1.98",
|
||||
"@getpaseo/protocol": "0.1.98",
|
||||
"@getpaseo/relay": "0.1.98",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/highlight": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
@@ -21,6 +20,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ProviderCatalog,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
/**
|
||||
@@ -206,15 +206,18 @@ class TestAgentClient implements AgentClient {
|
||||
return this.createSession(resolvedConfig);
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return [
|
||||
{
|
||||
provider: this.provider,
|
||||
id: "test-model",
|
||||
label: "Test Model",
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
provider: this.provider,
|
||||
id: "test-model",
|
||||
label: "Test Model",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
|
||||
@@ -99,25 +99,28 @@ class TestAgentClient implements AgentClient {
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.2-codex",
|
||||
label: "GPT-5.2 Codex",
|
||||
},
|
||||
];
|
||||
async fetchCatalog() {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.2-codex",
|
||||
label: "GPT-5.2 Codex",
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -667,8 +670,11 @@ test("setAgentMode persists the selected mode across session reload", async () =
|
||||
});
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }];
|
||||
async fetchCatalog() {
|
||||
return {
|
||||
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,15 +1460,18 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
async fetchCatalog() {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -5976,8 +5985,8 @@ class RecordingPersistedAgentsClient implements AgentClient {
|
||||
throw new Error(`unexpected resumeSession for ${this.provider}`);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [];
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
}
|
||||
|
||||
async listImportableSessions() {
|
||||
|
||||
@@ -3644,8 +3644,8 @@ export class AgentManager {
|
||||
const client = this.clients.get(normalized.provider);
|
||||
if (client) {
|
||||
try {
|
||||
const models = await client.listModels({ cwd: normalized.cwd, force: false });
|
||||
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
||||
const catalog = await client.fetchCatalog({ cwd: normalized.cwd, force: false });
|
||||
const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0];
|
||||
if (defaultModel) {
|
||||
normalized.model = defaultModel.id;
|
||||
}
|
||||
|
||||
@@ -636,14 +636,14 @@ export interface AgentSession {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
export interface FetchCatalogOptions {
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface ListModesOptions {
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
export interface ProviderCatalog {
|
||||
models: AgentModelDefinition[];
|
||||
modes: AgentMode[];
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
@@ -659,8 +659,13 @@ export interface AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
/**
|
||||
* Discover models and modes together. Implementations may use one upstream
|
||||
* process, separate upstream calls, static modes, or private helpers; callers
|
||||
* outside the provider do not get separate runtime model/mode probes.
|
||||
* The registry is responsible for merging configured model overrides.
|
||||
*/
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
|
||||
@@ -163,12 +163,9 @@ function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
|
||||
},
|
||||
resumeSession: async (handle, overrides, launchContext) =>
|
||||
await client.resumeSession(handle, overrides, launchContext),
|
||||
listModels: async (options) => await client.listModels(options),
|
||||
fetchCatalog: async (options) => await client.fetchCatalog(options),
|
||||
isAvailable: async () => await client.isAvailable(),
|
||||
};
|
||||
if (client.listModes) {
|
||||
wrappedClient.listModes = async (options) => await client.listModes!(options);
|
||||
}
|
||||
if (client.resolveCreateConfig) {
|
||||
wrappedClient.resolveCreateConfig = (input) => client.resolveCreateConfig!(input);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentModelDefinition } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentModelDefinition,
|
||||
AgentMode,
|
||||
ProviderCatalog,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
interface ConstructorEntry {
|
||||
runtimeSettings?: unknown;
|
||||
providerParams?: unknown;
|
||||
commandsRpcType?: unknown;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -76,12 +82,11 @@ vi.mock("./providers/claude/agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -125,12 +130,11 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -176,12 +180,11 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -212,12 +215,20 @@ vi.mock("./providers/pi/agent.js", () => ({
|
||||
readonly provider = "pi";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
|
||||
constructor(options: {
|
||||
runtimeSettings?: unknown;
|
||||
providerParams?: unknown;
|
||||
commandsRpcType?: unknown;
|
||||
}) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.pi.push({
|
||||
const entry: ConstructorEntry = {
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
};
|
||||
if (options.commandsRpcType !== undefined) {
|
||||
entry.commandsRpcType = options.commandsRpcType;
|
||||
}
|
||||
mockState.constructorArgs.pi.push(entry);
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
@@ -228,12 +239,11 @@ vi.mock("./providers/pi/agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -299,12 +309,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -353,12 +362,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -459,6 +467,7 @@ test("OMP is a disabled built-in backed by the Pi adapter", () => {
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,11 +498,10 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending pi passes params to the base provider constructor", () => {
|
||||
test("built-in OMP override passes params to the Pi adapter constructor", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
omp: {
|
||||
extends: "pi",
|
||||
label: "OMP",
|
||||
command: ["omp"],
|
||||
params: {
|
||||
@@ -516,6 +524,7 @@ test("new provider extending pi passes params to the base provider constructor",
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -800,7 +809,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.codex.fetchModels({
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -835,7 +844,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.codex.fetchModels({
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -873,7 +882,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.codex.fetchModels({
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -907,7 +916,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.codex.fetchModels({
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -949,7 +958,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -999,7 +1008,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1046,7 +1055,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.codex.fetchModels({
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1085,7 +1094,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1137,7 +1146,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1175,7 +1184,7 @@ describe("model merging", () => {
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1190,7 +1199,7 @@ describe("model merging", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("built-in createClient().listModels() honors profile model replacement (issue #579)", async () => {
|
||||
test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => {
|
||||
mockState.runtimeModels.set("codex", [
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -1215,16 +1224,16 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const client = registry.codex.createClient(logger);
|
||||
const models = await client.listModels({
|
||||
const catalog = await client.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("profile-fast");
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["profile-fast"]);
|
||||
expect(catalog.models.find((model) => model.isDefault)?.id).toBe("profile-fast");
|
||||
});
|
||||
|
||||
test("built-in createClient().listModels() honors additionalModels default (issue #579)", async () => {
|
||||
test("built-in createClient().fetchCatalog() honors additionalModels default (issue #579)", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
@@ -1249,12 +1258,12 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const client = registry.claude.createClient(logger);
|
||||
const models = await client.listModels({
|
||||
const catalog = await client.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
||||
const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0];
|
||||
expect(defaultModel?.id).toBe("profile-default");
|
||||
});
|
||||
|
||||
@@ -1277,7 +1286,7 @@ describe("model merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels({
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1286,3 +1295,111 @@ describe("model merging", () => {
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCatalog", () => {
|
||||
test("returns merged models and modes from fetchCatalog", async () => {
|
||||
mockState.runtimeModels.set("codex", [
|
||||
{ provider: "codex", id: "codex-runtime", label: "Codex Runtime" },
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["codex-runtime"]);
|
||||
expect(catalog.modes).toEqual([]);
|
||||
});
|
||||
|
||||
test("replacement models skip runtime model discovery but preserve additionalModels", async () => {
|
||||
mockState.runtimeModels.set("codex", [
|
||||
{ provider: "codex", id: "codex-runtime", label: "Codex Runtime" },
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
codex: {
|
||||
models: [{ id: "profile-model", label: "Profile Model" }],
|
||||
additionalModels: [{ id: "extra-model", label: "Extra Model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]);
|
||||
});
|
||||
|
||||
test("additionalModels can override replacement model fields", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
codex: {
|
||||
models: [{ id: "shared-model", label: "Profile Label" }],
|
||||
additionalModels: [{ id: "shared-model", label: "Additional Label" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
id: "shared-model",
|
||||
label: "Additional Label",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses injected client instead of base client when provided", async () => {
|
||||
const injectedModels: AgentModelDefinition[] = [
|
||||
{ provider: "codex", id: "injected-model", label: "Injected Model" },
|
||||
];
|
||||
const injectedModes: AgentMode[] = [{ id: "agent", label: "Agent" }];
|
||||
const injectedClient = {
|
||||
provider: "codex",
|
||||
capabilities: {},
|
||||
fetchCatalog: vi.fn(async () => ({ models: injectedModels, modes: injectedModes })),
|
||||
isAvailable: vi.fn(async () => true),
|
||||
} satisfies Partial<AgentClient> as AgentClient;
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const catalog = await registry.codex.fetchCatalog(
|
||||
{ cwd: "/tmp/catalog", force: false },
|
||||
injectedClient,
|
||||
);
|
||||
|
||||
expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["injected-model"]);
|
||||
expect(catalog.modes).toEqual(injectedModes);
|
||||
});
|
||||
|
||||
test("uses injected client fetchCatalog when available", async () => {
|
||||
const injectedClient = {
|
||||
provider: "codex",
|
||||
capabilities: {},
|
||||
fetchCatalog: vi.fn(async () => ({
|
||||
models: [{ provider: "codex", id: "catalog-model", label: "Catalog Model" }],
|
||||
modes: [{ id: "ask", label: "Ask" }],
|
||||
})),
|
||||
isAvailable: vi.fn(async () => true),
|
||||
} satisfies Partial<AgentClient> as AgentClient;
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const catalog = await registry.codex.fetchCatalog(
|
||||
{ cwd: "/tmp/catalog", force: false },
|
||||
injectedClient,
|
||||
);
|
||||
|
||||
expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["catalog-model"]);
|
||||
expect(catalog.modes.map((mode) => mode.id)).toEqual(["ask"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
FetchCatalogOptions,
|
||||
ProviderCatalog,
|
||||
ResolveAgentCreateConfigInput,
|
||||
ResolveAgentCreateConfigResult,
|
||||
} from "./agent-sdk-types.js";
|
||||
@@ -64,8 +64,11 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
createClient: (logger: Logger) => AgentClient;
|
||||
resolveCreateConfig: (input: ResolveAgentCreateConfigInput) => ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended: (input: AgentCreateConfigUnattendedInput) => boolean;
|
||||
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
/**
|
||||
* Single catalog discovery call used by ProviderSnapshotManager. Should spawn
|
||||
* at most one provider runtime process and return both models and modes.
|
||||
*/
|
||||
fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise<ProviderCatalog>;
|
||||
}
|
||||
|
||||
export interface BuildProviderRegistryOptions {
|
||||
@@ -153,6 +156,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
providerParams: options?.providerParams ?? {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
@@ -424,11 +428,15 @@ function wrapClientProvider(
|
||||
launchContext,
|
||||
),
|
||||
),
|
||||
listModels: async (options) =>
|
||||
mergeModels(provider, profileModels, additionalModels, await inner.listModels(options), {
|
||||
profileModelsAreAdditive,
|
||||
}),
|
||||
listModes: inner.listModes?.bind(inner),
|
||||
fetchCatalog: async (options) => {
|
||||
const catalog = await inner.fetchCatalog(options);
|
||||
return {
|
||||
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||
profileModelsAreAdditive,
|
||||
}),
|
||||
modes: catalog.modes,
|
||||
};
|
||||
},
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
listImportableSessions: listImportableSessions
|
||||
@@ -473,6 +481,24 @@ function createRegistryEntry(
|
||||
resolved: ResolvedProvider,
|
||||
): ProviderDefinition {
|
||||
const modelClient = resolved.createBaseClient(logger);
|
||||
const hasReplacementModels =
|
||||
resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive;
|
||||
const replacementModels = hasReplacementModels
|
||||
? resolved.profileModels.map((model) => mapModel(provider, model))
|
||||
: [];
|
||||
|
||||
const decorateModes = (modes: AgentMode[]): AgentMode[] =>
|
||||
modes.map((mode) => {
|
||||
if (mode.icon && mode.colorTier) return mode;
|
||||
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
|
||||
if (!definitionMode) return mode;
|
||||
return Object.assign({}, mode, {
|
||||
icon: mode.icon ?? definitionMode.icon,
|
||||
colorTier: mode.colorTier ?? definitionMode.colorTier,
|
||||
});
|
||||
});
|
||||
|
||||
const hasStaticModes = resolved.definition.modes.length > 0;
|
||||
|
||||
return {
|
||||
...resolved.definition,
|
||||
@@ -483,29 +509,36 @@ function createRegistryEntry(
|
||||
resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig,
|
||||
isCreateConfigUnattended:
|
||||
modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended,
|
||||
fetchModels: async (options: ListModelsOptions) =>
|
||||
mergeModels(
|
||||
provider,
|
||||
resolved.profileModels,
|
||||
resolved.additionalModels,
|
||||
await modelClient.listModels(options),
|
||||
{
|
||||
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||
},
|
||||
),
|
||||
fetchModes: async (options: ListModesOptions) => {
|
||||
const modes = modelClient.listModes
|
||||
? await modelClient.listModes(options)
|
||||
: resolved.definition.modes;
|
||||
return modes.map((mode) => {
|
||||
if (mode.icon && mode.colorTier) return mode;
|
||||
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
|
||||
if (!definitionMode) return mode;
|
||||
return Object.assign({}, mode, {
|
||||
icon: mode.icon ?? definitionMode.icon,
|
||||
colorTier: mode.colorTier ?? definitionMode.colorTier,
|
||||
});
|
||||
});
|
||||
fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => {
|
||||
const catalogClient = client ?? modelClient;
|
||||
if (hasReplacementModels) {
|
||||
// Replacement models skip runtime model discovery, but additionalModels
|
||||
// must still be merged on top. If modes are dynamic, probe for modes via
|
||||
// the single catalog API; otherwise use static/empty modes with no runtime.
|
||||
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
|
||||
if (hasStaticModes) {
|
||||
return {
|
||||
models,
|
||||
modes: decorateModes(resolved.definition.modes),
|
||||
};
|
||||
}
|
||||
const catalog = await catalogClient.fetchCatalog(options);
|
||||
return { models, modes: decorateModes(catalog.modes) };
|
||||
}
|
||||
|
||||
const catalog = await catalogClient.fetchCatalog(options);
|
||||
return {
|
||||
models: mergeModels(
|
||||
provider,
|
||||
resolved.profileModels,
|
||||
resolved.additionalModels,
|
||||
catalog.models,
|
||||
{
|
||||
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||
},
|
||||
),
|
||||
modes: decorateModes(catalog.modes),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ListModelsOptions,
|
||||
FetchCatalogOptions,
|
||||
ResolveAgentCreateConfigInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
@@ -38,8 +38,8 @@ function createExtraClient(
|
||||
async resumeSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async listModels(_options: ListModelsOptions) {
|
||||
return [] as AgentModelDefinition[];
|
||||
async fetchCatalog(_options: FetchCatalogOptions) {
|
||||
return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] };
|
||||
},
|
||||
async isAvailable() {
|
||||
return false;
|
||||
@@ -107,7 +107,10 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
|
||||
test("providerOverrides with enabled:false marks the provider as unavailable without probing", async () => {
|
||||
const isAvailable = vi.fn(async () => true);
|
||||
const fetchModels = vi.fn(async () => [] as AgentModelDefinition[]);
|
||||
const fetchCatalog = vi.fn(async () => ({
|
||||
models: [] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
@@ -118,7 +121,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
pi: { enabled: false },
|
||||
},
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", { isAvailable, listModels: fetchModels }),
|
||||
codex: createExtraClient("codex", { isAvailable, fetchCatalog }),
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -126,7 +129,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
const codex = entries.find((entry) => entry.provider === "codex");
|
||||
expect(codex).toMatchObject({ provider: "codex", enabled: false, status: "unavailable" });
|
||||
expect(isAvailable).not.toHaveBeenCalled();
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
expect(fetchCatalog).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
@@ -161,18 +164,20 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
test("wait:true returns a warm provider without refreshing it", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const isAvailable = vi.fn(async () => true);
|
||||
const listModels = vi.fn(async () => [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT 5.4 Mini",
|
||||
},
|
||||
]);
|
||||
const listModes = vi.fn(async () => [] as AgentMode[]);
|
||||
const fetchCatalog = vi.fn(async () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT 5.4 Mini",
|
||||
},
|
||||
] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", { isAvailable, listModels, listModes }),
|
||||
codex: createExtraClient("codex", { isAvailable, fetchCatalog }),
|
||||
},
|
||||
});
|
||||
const listener = vi.fn();
|
||||
@@ -181,16 +186,14 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
const [first] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
|
||||
expect(first).toMatchObject({ provider: "codex", status: "ready" });
|
||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(listModels).toHaveBeenCalledTimes(1);
|
||||
expect(listModes).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
|
||||
listener.mockClear();
|
||||
const [second] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(listModels).toHaveBeenCalledTimes(1);
|
||||
expect(listModes).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
manager.destroy();
|
||||
@@ -200,35 +203,37 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
test("explicit refresh re-probes only the requested warm provider", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const isAvailableCodex = vi.fn(async () => true);
|
||||
const listCodexModels = vi.fn(async () => [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT 5.4 Mini",
|
||||
},
|
||||
]);
|
||||
const listCodexModes = vi.fn(async () => [] as AgentMode[]);
|
||||
const fetchCodexCatalog = vi.fn(async () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT 5.4 Mini",
|
||||
},
|
||||
] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const isAvailableClaude = vi.fn(async () => true);
|
||||
const listClaudeModels = vi.fn(async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4.5",
|
||||
label: "Claude Opus 4.5",
|
||||
},
|
||||
]);
|
||||
const listClaudeModes = vi.fn(async () => [] as AgentMode[]);
|
||||
const fetchClaudeCatalog = vi.fn(async () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4.5",
|
||||
label: "Claude Opus 4.5",
|
||||
},
|
||||
] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
isAvailable: isAvailableCodex,
|
||||
listModels: listCodexModels,
|
||||
listModes: listCodexModes,
|
||||
fetchCatalog: fetchCodexCatalog,
|
||||
}),
|
||||
claude: createExtraClient("claude", {
|
||||
isAvailable: isAvailableClaude,
|
||||
listModels: listClaudeModels,
|
||||
listModes: listClaudeModes,
|
||||
fetchCatalog: fetchClaudeCatalog,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -237,11 +242,9 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
await manager.refreshSnapshotForCwd({ cwd, providers: ["codex"] });
|
||||
|
||||
expect(isAvailableCodex).toHaveBeenCalledTimes(2);
|
||||
expect(listCodexModels).toHaveBeenCalledTimes(2);
|
||||
expect(listCodexModes).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCodexCatalog).toHaveBeenCalledTimes(2);
|
||||
expect(isAvailableClaude).toHaveBeenCalledTimes(1);
|
||||
expect(listClaudeModels).toHaveBeenCalledTimes(1);
|
||||
expect(listClaudeModes).toHaveBeenCalledTimes(1);
|
||||
expect(fetchClaudeCatalog).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
@@ -431,7 +434,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getProviderDiagnostic returns the diagnostic from the injected client", async () => {
|
||||
test("getProviderDiagnostic returns the diagnostic from the injected client and appends snapshot models/status", async () => {
|
||||
const getDiagnostic = vi.fn(async () => ({ diagnostic: "codex is ready" }));
|
||||
const client = createExtraClient("codex", { getDiagnostic });
|
||||
const manager = new ProviderSnapshotManager({
|
||||
@@ -440,14 +443,44 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
});
|
||||
try {
|
||||
const result = await manager.getProviderDiagnostic("codex");
|
||||
expect(result).toEqual({ provider: "codex", diagnostic: "codex is ready" });
|
||||
expect(result.provider).toBe("codex");
|
||||
expect(result.diagnostic).toContain("codex is ready");
|
||||
expect(result.diagnostic).toContain("Models:");
|
||||
expect(result.diagnostic).toContain("Status:");
|
||||
expect(getDiagnostic).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic", async () => {
|
||||
test("getProviderDiagnostic force-refreshes the snapshot via a single fetchCatalog call", async () => {
|
||||
const catalogModels: AgentModelDefinition[] = [
|
||||
{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" },
|
||||
];
|
||||
const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }];
|
||||
const fetchCatalog = vi.fn(async () => ({
|
||||
models: catalogModels,
|
||||
modes: catalogModes,
|
||||
}));
|
||||
const client = createExtraClient("codex", {
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog,
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: { codex: client },
|
||||
});
|
||||
try {
|
||||
const result = await manager.getProviderDiagnostic("codex");
|
||||
expect(fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(result.diagnostic).toContain("Models: 1");
|
||||
expect(result.diagnostic).toContain("Status: Ready");
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic and appends snapshot models/status", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: { codex: createExtraClient("codex") },
|
||||
@@ -456,15 +489,35 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
const result = await manager.getProviderDiagnostic("codex");
|
||||
expect(result.provider).toBe("codex");
|
||||
expect(result.diagnostic).toMatch(/no diagnostic/i);
|
||||
expect(result.diagnostic).toContain("Models:");
|
||||
expect(result.diagnostic).toContain("Status:");
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("getProviderDiagnostic throws when no client is configured for the provider", async () => {
|
||||
test("getProviderDiagnostic materializes the client and proceeds for an unmaterialized configured provider", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
isDev: true,
|
||||
extraClients: {},
|
||||
});
|
||||
try {
|
||||
const result = await manager.getProviderDiagnostic("mock");
|
||||
expect(result.provider).toBe("mock");
|
||||
expect(result.diagnostic).toContain("Models:");
|
||||
expect(result.diagnostic).toContain("Status:");
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("getProviderDiagnostic throws for an unknown provider", async () => {
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
try {
|
||||
await expect(manager.getProviderDiagnostic("codex")).rejects.toThrow(/not configured/);
|
||||
await expect(
|
||||
manager.getProviderDiagnostic("unknown-provider" as AgentProvider),
|
||||
).rejects.toThrow(/not configured/);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
@@ -509,8 +562,8 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async listModes() {
|
||||
return childModes;
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes: childModes };
|
||||
},
|
||||
async resolveCreateConfig(input) {
|
||||
resolverInputs.push(input);
|
||||
@@ -524,8 +577,8 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async listModes() {
|
||||
return parentModes;
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes: parentModes };
|
||||
},
|
||||
isCreateConfigUnattended(input) {
|
||||
return input.modeId === "parent-unattended";
|
||||
@@ -587,8 +640,8 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async listModes() {
|
||||
return modes;
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes };
|
||||
},
|
||||
async resolveCreateConfig(input) {
|
||||
resolverInputs.push(input);
|
||||
@@ -646,8 +699,8 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async listModes() {
|
||||
return modes;
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes };
|
||||
},
|
||||
resolveCreateConfig: openCode.resolveCreateConfig.bind(openCode),
|
||||
isCreateConfigUnattended: openCode.isCreateConfigUnattended.bind(openCode),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type ProviderDefinition,
|
||||
} from "./provider-registry.js";
|
||||
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
|
||||
import { formatProviderDiagnostic } from "./providers/diagnostic-utils.js";
|
||||
import type { MutableDaemonConfig } from "../daemon-config-store.js";
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
||||
@@ -312,13 +313,23 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
async getProviderDiagnostic(provider: AgentProvider): Promise<ProviderDiagnosticResult> {
|
||||
const client = this.providerClients[provider];
|
||||
if (!client) {
|
||||
throw new Error(`Provider ${provider} is not configured`);
|
||||
}
|
||||
const diagnostic = client.getDiagnostic
|
||||
const definition = this.requireProvider(provider);
|
||||
const client = this.ensureClient(provider, definition);
|
||||
|
||||
// Force-refresh the snapshot so Models/Status come from the single catalog authority.
|
||||
await this.refreshSnapshotForCwd({ cwd: homedir(), providers: [provider] });
|
||||
const entry = await this.getProvider({ cwd: homedir(), provider, wait: true });
|
||||
|
||||
const modelCount = entry.status === "ready" ? String(entry.models?.length ?? 0) : "—";
|
||||
const status = formatProviderStatus(entry);
|
||||
|
||||
const baseDiagnostic = client.getDiagnostic
|
||||
? (await client.getDiagnostic()).diagnostic
|
||||
: "No diagnostic available for this provider.";
|
||||
: formatProviderDiagnostic(definition.label ?? provider, [
|
||||
{ label: "Diagnostic", value: "No diagnostic available" },
|
||||
]);
|
||||
|
||||
const diagnostic = `${baseDiagnostic}\n Models: ${modelCount}\n Status: ${status}`;
|
||||
return { provider, diagnostic };
|
||||
}
|
||||
|
||||
@@ -390,8 +401,7 @@ export class ProviderSnapshotManager {
|
||||
client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig,
|
||||
isCreateConfigUnattended:
|
||||
client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended,
|
||||
fetchModels: client.listModels.bind(client),
|
||||
fetchModes: client.listModes?.bind(client) ?? definition.fetchModes,
|
||||
fetchCatalog: client.fetchCatalog.bind(client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -644,11 +654,8 @@ export class ProviderSnapshotManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const [models, modes] = await withTimeout(
|
||||
Promise.all([
|
||||
definition.fetchModels({ cwd, force }),
|
||||
definition.fetchModes({ cwd, force }),
|
||||
]),
|
||||
const catalog = await withTimeout(
|
||||
definition.fetchCatalog({ cwd, force }, client),
|
||||
this.refreshTimeoutMs,
|
||||
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
|
||||
);
|
||||
@@ -657,8 +664,8 @@ export class ProviderSnapshotManager {
|
||||
...base,
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models,
|
||||
modes,
|
||||
models: catalog.models,
|
||||
modes: catalog.modes,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -806,3 +813,10 @@ function toErrorMessage(error: unknown): string {
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
function formatProviderStatus(entry: ProviderSnapshotEntry): string {
|
||||
if (entry.status === "ready") return "Ready";
|
||||
if (entry.status === "error") return `Error: ${entry.error ?? "Unknown error"}`;
|
||||
if (entry.status === "unavailable") return "Unavailable";
|
||||
return "Loading";
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./acp-agent.js";
|
||||
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
||||
import {
|
||||
COPILOT_AGENT_FEATURE_OPTION,
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
CopilotACPAgentClient,
|
||||
@@ -205,12 +206,16 @@ function selectConfigOption(
|
||||
};
|
||||
}
|
||||
|
||||
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
|
||||
function createCopilotSessionWithConfig(
|
||||
modeId?: string | null,
|
||||
featureValues?: Record<string, unknown>,
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
@@ -219,6 +224,7 @@ function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
@@ -272,6 +278,27 @@ function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigO
|
||||
};
|
||||
}
|
||||
|
||||
function copilotAgentConfigOption(currentValue: string): SessionConfigOption {
|
||||
return {
|
||||
id: "agent",
|
||||
name: "Agent",
|
||||
category: "_agent",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{
|
||||
value: "",
|
||||
name: "",
|
||||
},
|
||||
{
|
||||
value: "Probe Agent",
|
||||
name: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
||||
if (category === "mode") {
|
||||
return "Mode";
|
||||
@@ -1137,6 +1164,90 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
]);
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
|
||||
test("exposes Copilot custom agents as a select feature", () => {
|
||||
const session = createCopilotSessionWithConfig();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: "agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
icon: undefined,
|
||||
value: "",
|
||||
options: [
|
||||
{
|
||||
id: "",
|
||||
label: "Default",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "Probe Agent",
|
||||
label: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("applies configured Copilot custom agent before the first turn", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}));
|
||||
const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" });
|
||||
const { internals } = prepareConfiguredOverrideSession(session, {
|
||||
configOptions: [copilotAgentConfigOption("")],
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await internals.applyConfiguredOverrides();
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("sets Copilot custom agent through ACP config options", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}));
|
||||
const session = createCopilotSessionWithConfig();
|
||||
prepareConfiguredOverrideSession(session, {
|
||||
configOptions: [copilotAgentConfigOption("")],
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await session.setFeature("agent", "Probe Agent");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
@@ -1268,16 +1379,64 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
modelTransformer: transformPiModels,
|
||||
});
|
||||
|
||||
await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient config features", () => {
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "copilot",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
options: [
|
||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1307,7 +1466,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
test("applies sessionResponseTransformer before deriving list probe modes", async () => {
|
||||
test("applies sessionResponseTransformer before deriving catalog modes", async () => {
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "claude-acp",
|
||||
logger: createTestLogger(),
|
||||
@@ -1322,18 +1481,21 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
description: "After transform",
|
||||
},
|
||||
]);
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
description: "After transform",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient listModes", () => {
|
||||
test("passes the requested cwd to list model and mode probes", async () => {
|
||||
describe("ACPAgentClient fetchCatalog", () => {
|
||||
test("passes the requested cwd to the catalog probe", async () => {
|
||||
const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] });
|
||||
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
@@ -1355,20 +1517,15 @@ describe("ACPAgentClient listModes", () => {
|
||||
defaultModes: [],
|
||||
});
|
||||
|
||||
await client.listModels({ cwd: "/tmp/acp-model-cwd", force: false });
|
||||
await client.listModes({ cwd: "/tmp/acp-mode-cwd", force: false });
|
||||
await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false });
|
||||
|
||||
expect(newSession).toHaveBeenNthCalledWith(1, {
|
||||
cwd: "/tmp/acp-model-cwd",
|
||||
mcpServers: [],
|
||||
});
|
||||
expect(newSession).toHaveBeenNthCalledWith(2, {
|
||||
cwd: "/tmp/acp-mode-cwd",
|
||||
expect(newSession).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/acp-catalog-cwd",
|
||||
mcpServers: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
|
||||
test("returns an empty modes array when no ACP modes are reported and fallback modes are empty", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
@@ -1406,7 +1563,10 @@ describe("ACPAgentClient listModes", () => {
|
||||
defaultModes: [],
|
||||
});
|
||||
|
||||
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([]);
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1912,6 +2072,130 @@ describe("ACPAgentSession", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("startTurn dedupes ACP user echo chunks without message ids for the submitted message", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await session.startTurn("hello", { messageId: "msg-client-1" });
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
} as SessionUpdate,
|
||||
});
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude-acp",
|
||||
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
|
||||
turnId: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn dedupes ACP user echo chunks without message ids across turns", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
let resolvePrompt!: (value: PromptResponse) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise<PromptResponse>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await session.startTurn("first", { messageId: "msg-client-1" });
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "first" },
|
||||
} as SessionUpdate,
|
||||
});
|
||||
resolvePrompt({ stopReason: "end_turn" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await session.startTurn("second", { messageId: "msg-client-2" });
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "second" },
|
||||
} as SessionUpdate,
|
||||
});
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude-acp",
|
||||
item: { type: "user_message", text: "first", messageId: "msg-client-1" },
|
||||
turnId: expect.any(String),
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude-acp",
|
||||
item: { type: "user_message", text: "second", messageId: "msg-client-2" },
|
||||
turnId: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn dedupes ACP user echo chunks with provider-owned ids for the submitted message", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await session.startTurn("hello", { messageId: "msg-client-1" });
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg-provider-1",
|
||||
content: { type: "text", text: "hello" },
|
||||
} as SessionUpdate,
|
||||
});
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude-acp",
|
||||
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
|
||||
turnId: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
|
||||
@@ -2159,7 +2443,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.listModels({ cwd: "/tmp/acp-models", force: false });
|
||||
await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false });
|
||||
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.stdin.destroyed).toBe(true);
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -81,13 +82,13 @@ import {
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -318,6 +319,7 @@ interface ACPAgentClientOptions {
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -344,6 +346,7 @@ interface ACPAgentSessionOptions {
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -393,6 +396,12 @@ interface MessageAssemblyState {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface SubmittedUserMessageEcho {
|
||||
messageId: string;
|
||||
text: string;
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
|
||||
|
||||
interface TerminalExit {
|
||||
@@ -420,6 +429,17 @@ interface ConfigOptionSelector {
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
export interface ACPConfigFeatureOption {
|
||||
id: string;
|
||||
configId: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
emptyOptionLabel?: string;
|
||||
}
|
||||
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
||||
interface SelectConfigChoice {
|
||||
value: string;
|
||||
@@ -579,6 +599,31 @@ export function deriveModelDefinitionsFromACP(
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveFeaturesFromACP(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOptions: ACPConfigFeatureOption[],
|
||||
): AgentFeature[] {
|
||||
return featureOptions.flatMap((featureOption) => {
|
||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||
if (!option) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: featureOption.id,
|
||||
label: featureOption.label,
|
||||
description: featureOption.description,
|
||||
tooltip: featureOption.tooltip,
|
||||
icon: featureOption.icon,
|
||||
value: option.currentValue ?? null,
|
||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export class ACPAgentClient implements AgentClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
@@ -594,6 +639,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
@@ -625,6 +671,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
@@ -650,6 +697,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
configFeatureOptions: this.configFeatureOptions,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
@@ -696,6 +744,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
configFeatureOptions: this.configFeatureOptions,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
@@ -712,7 +761,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
@@ -728,29 +777,36 @@ export class ACPAgentClient implements AgentClient {
|
||||
transformed.models,
|
||||
transformed.configOptions,
|
||||
);
|
||||
return this.modelTransformer ? this.modelTransformer(models) : models;
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
}
|
||||
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await this.runACPRequest(() =>
|
||||
probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
const modeInfo = deriveModesFromACP(
|
||||
this.defaultModes,
|
||||
transformed.modes,
|
||||
transformed.configOptions,
|
||||
);
|
||||
return modeInfo.modes;
|
||||
return {
|
||||
models: this.modelTransformer ? this.modelTransformer(models) : models,
|
||||
modes: modeInfo.modes,
|
||||
};
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await this.runACPRequest(() =>
|
||||
probe.connection.newSession({
|
||||
cwd: config.cwd,
|
||||
mcpServers: [],
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions);
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
@@ -973,6 +1029,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
@@ -992,6 +1049,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||
private readonly messageAssemblies = new Map<string, MessageAssemblyState>();
|
||||
private readonly submittedUserMessageIds = new Set<string>();
|
||||
private activeSubmittedUserMessage: SubmittedUserMessageEcho | null = null;
|
||||
private readonly toolCalls = new Map<string, ACPToolSnapshot>();
|
||||
private readonly terminalEntries = new Map<string, TerminalEntry>();
|
||||
private readonly persistedHistory: AgentTimelineItem[] = [];
|
||||
@@ -1034,6 +1092,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
@@ -1150,6 +1209,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const turnId = randomUUID();
|
||||
const messageId = options?.messageId ?? randomUUID();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeSubmittedUserMessage = null;
|
||||
this.emitBootstrapThreadEvent();
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.emitSubmittedUserMessage(prompt, messageId, turnId);
|
||||
@@ -1217,6 +1277,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions);
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
||||
return;
|
||||
@@ -1549,6 +1613,44 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
|
||||
if (!featureOption) {
|
||||
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
|
||||
}
|
||||
|
||||
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||
if (!option) {
|
||||
throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`);
|
||||
}
|
||||
|
||||
const requestedValue = normalizeConfigFeatureValue(value);
|
||||
const choice = findSelectConfigChoice({ option, value: requestedValue });
|
||||
if (!choice) {
|
||||
throw new Error(
|
||||
`${this.provider} feature '${featureId}' does not include option '${requestedValue}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.connection.setSessionConfigOption({
|
||||
sessionId: this.sessionId,
|
||||
configId: option.id,
|
||||
value: requestedValue,
|
||||
});
|
||||
const currentValue = this.applyConfigOptionResponse({
|
||||
response,
|
||||
configId: option.id,
|
||||
category: featureOption.category,
|
||||
requestedValue,
|
||||
label: featureOption.label,
|
||||
});
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
||||
}
|
||||
|
||||
private applyConfigOptionResponse({
|
||||
response,
|
||||
configId,
|
||||
@@ -2009,6 +2111,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||
}
|
||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
}
|
||||
}
|
||||
|
||||
private warnInvalidSelection(value: string, message: string): void {
|
||||
@@ -2030,7 +2139,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
if (update.messageId && this.submittedUserMessageIds.has(update.messageId)) {
|
||||
if (item.type !== "user_message") {
|
||||
return [this.wrapTimeline(item)];
|
||||
}
|
||||
if (this.isSubmittedUserMessageEcho(item)) {
|
||||
return [];
|
||||
}
|
||||
return [this.wrapTimeline(item)];
|
||||
@@ -2113,7 +2225,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (!chunkText) {
|
||||
return null;
|
||||
}
|
||||
const key = `${type}:${update.messageId ?? "default"}`;
|
||||
const key = this.messageAssemblyKey(type, update.messageId);
|
||||
const state = this.messageAssemblies.get(key) ?? { text: "" };
|
||||
state.text += chunkText;
|
||||
this.messageAssemblies.set(key, state);
|
||||
@@ -2127,6 +2239,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return { type: "reasoning", text: chunkText };
|
||||
}
|
||||
|
||||
private messageAssemblyKey(
|
||||
type: "user_message" | "assistant_message" | "reasoning",
|
||||
messageId: string | null | undefined,
|
||||
): string {
|
||||
const fallbackId =
|
||||
type === "user_message" ? (this.activeForegroundTurnId ?? "default") : "default";
|
||||
return `${type}:${messageId ?? fallbackId}`;
|
||||
}
|
||||
|
||||
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
|
||||
this.currentMode = this.transformModeId(update.currentModeId);
|
||||
}
|
||||
@@ -2245,6 +2366,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
this.submittedUserMessageIds.add(messageId);
|
||||
this.activeSubmittedUserMessage = { messageId, text, turnId };
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -2271,9 +2393,27 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
): void {
|
||||
this.activeForegroundTurnId = null;
|
||||
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
|
||||
this.activeSubmittedUserMessage = null;
|
||||
}
|
||||
this.pushEvent(event);
|
||||
}
|
||||
|
||||
private isSubmittedUserMessageEcho(
|
||||
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
||||
): boolean {
|
||||
const active = this.activeSubmittedUserMessage;
|
||||
if (!active || active.turnId !== this.activeForegroundTurnId) {
|
||||
return false;
|
||||
}
|
||||
if (item.messageId) {
|
||||
if (this.submittedUserMessageIds.has(item.messageId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return active.text.startsWith(item.text);
|
||||
}
|
||||
|
||||
private emitBootstrapThreadEvent(): void {
|
||||
if (!this.bootstrapThreadEventPending || !this.sessionId) {
|
||||
return;
|
||||
@@ -2337,6 +2477,19 @@ function findSelectConfigOption({
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
function findSelectConfigFeatureOption(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): SelectConfigOption | null {
|
||||
const option = configOptions?.find(
|
||||
(entry): entry is SelectConfigOption =>
|
||||
entry.type === "select" &&
|
||||
entry.id === featureOption.configId &&
|
||||
entry.category === featureOption.category,
|
||||
);
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
function findSelectConfigChoice({
|
||||
option,
|
||||
value,
|
||||
@@ -2364,6 +2517,43 @@ function flattenSelectOptions(options: SelectConfigOption["options"]): SelectCon
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function deriveConfigFeatureSelectOptions(
|
||||
option: SelectConfigOption,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): ConfigOptionSelector[] {
|
||||
return flattenSelectOptions(option.options).map((choice) => ({
|
||||
id: choice.value,
|
||||
label: normalizeConfigFeatureOptionLabel(choice, featureOption),
|
||||
description: choice.description ?? undefined,
|
||||
isDefault: choice.value === option.currentValue,
|
||||
metadata: choice.group ? { group: choice.group } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfigFeatureOptionLabel(
|
||||
choice: SelectConfigChoice,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): string {
|
||||
const name = choice.name.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (choice.value === "" && featureOption.emptyOptionLabel) {
|
||||
return featureOption.emptyOptionLabel;
|
||||
}
|
||||
return choice.value;
|
||||
}
|
||||
|
||||
function normalizeConfigFeatureValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`ACP feature value must be a string`);
|
||||
}
|
||||
|
||||
function deriveSelectorOptions(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
category: string,
|
||||
|
||||
@@ -395,7 +395,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
// "interrupting message should produce coherent text without garbling from race condition"
|
||||
// in daemon.e2e.test.ts which exercises the full flow through the WebSocket API.
|
||||
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("returns hardcoded claude models", async () => {
|
||||
@@ -406,7 +406,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-fable-5",
|
||||
@@ -441,7 +441,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
|
||||
const getThinkingIds = (modelId: string) => {
|
||||
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
|
||||
};
|
||||
@@ -1046,7 +1046,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
interface QueryFactoryForTurnsOptions {
|
||||
currentContextUsageByTurn?: Array<Record<string, unknown> | undefined>;
|
||||
getContextUsage?: ReturnType<typeof vi.fn>;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@@ -1091,8 +1091,8 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let turnIndex = 0;
|
||||
let contextUsageIndex = 0;
|
||||
const closedRef = { value: false };
|
||||
const getContextUsage = options?.getContextUsage ?? vi.fn(async () => undefined);
|
||||
|
||||
function wakeNextWaiter() {
|
||||
const waiter = waiters.shift();
|
||||
@@ -1140,11 +1140,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
getContextUsage: vi.fn(async () => {
|
||||
const usage = options?.currentContextUsageByTurn?.[contextUsageIndex];
|
||||
contextUsageIndex += 1;
|
||||
return usage;
|
||||
}),
|
||||
getContextUsage,
|
||||
supportedModels: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
@@ -1165,26 +1161,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createClaudeCurrentContextUsage(
|
||||
totalTokens: number,
|
||||
maxTokens: number,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
categories: [],
|
||||
totalTokens,
|
||||
maxTokens,
|
||||
rawMaxTokens: maxTokens,
|
||||
percentage: totalTokens / maxTokens,
|
||||
gridRows: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
memoryFiles: [],
|
||||
mcpTools: [],
|
||||
agents: [],
|
||||
isAutoCompactEnabled: true,
|
||||
apiUsage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createSuccessResult(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
type: "result",
|
||||
@@ -1282,6 +1258,21 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactBoundary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
compact_metadata: {
|
||||
trigger: "manual",
|
||||
pre_tokens: 14_990,
|
||||
post_tokens: 704,
|
||||
},
|
||||
uuid: "compact-boundary-1",
|
||||
session_id: "session-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("passes persistSession through to the Claude SDK query options", async () => {
|
||||
const createResultTurn = (sessionId: string) => [
|
||||
{
|
||||
@@ -1556,7 +1547,10 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reports Claude's current context usage after an Agent subagent runs", async () => {
|
||||
test("does not probe current context usage after an Agent subagent runs", async () => {
|
||||
const getContextUsage = vi.fn(async () => {
|
||||
throw new Error("getContextUsage should not be called during result handling");
|
||||
});
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
@@ -1575,21 +1569,20 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}),
|
||||
],
|
||||
],
|
||||
{
|
||||
currentContextUsageByTurn: [createClaudeCurrentContextUsage(12_345, 200_000)],
|
||||
},
|
||||
{ getContextUsage },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("turn");
|
||||
|
||||
expect(getContextUsage).not.toHaveBeenCalled();
|
||||
expect(result.usage).toEqual({
|
||||
inputTokens: 9_000,
|
||||
cachedInputTokens: 700,
|
||||
outputTokens: 400,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 12_345,
|
||||
contextWindowUsedTokens: 175,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -1637,6 +1630,121 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("uses parent request usage after a real subagent tool result", async () => {
|
||||
const getContextUsage = vi.fn(async () => {
|
||||
throw new Error("getContextUsage should not be called during result handling");
|
||||
});
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent({
|
||||
input_tokens: 3,
|
||||
cache_creation_input_tokens: 16_999,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
createAgentToolStartEvent(),
|
||||
createMessageDeltaEvent(163),
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu-agent-1",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "SUBAGENT_OK" }],
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
cache_creation_input_tokens: 1_182,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 8,
|
||||
},
|
||||
},
|
||||
uuid: "subagent-assistant-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
{
|
||||
...createSubagentTaskNotification(),
|
||||
status: "completed",
|
||||
summary: "Probe subagent test",
|
||||
usage: {
|
||||
total_tokens: 1_193,
|
||||
tool_uses: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu-agent-1",
|
||||
content: [
|
||||
{ type: "text", text: "SUBAGENT_OK" },
|
||||
{
|
||||
type: "text",
|
||||
text: "agentId: subagent-1\n<usage>subagent_tokens: 1194\ntool_uses: 0</usage>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: "subagent-tool-result-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
createMessageStartEvent({
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 253,
|
||||
cache_read_input_tokens: 16_999,
|
||||
}),
|
||||
createMessageDeltaEvent(8),
|
||||
createSuccessResult({
|
||||
usage: {
|
||||
input_tokens: 4,
|
||||
cache_creation_input_tokens: 17_252,
|
||||
cache_read_input_tokens: 16_999,
|
||||
output_tokens: 171,
|
||||
iterations: [
|
||||
{
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 253,
|
||||
cache_read_input_tokens: 16_999,
|
||||
output_tokens: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
modelUsage: {
|
||||
"claude-sonnet-4-6": {
|
||||
inputTokens: 7,
|
||||
outputTokens: 180,
|
||||
cacheReadInputTokens: 16_999,
|
||||
cacheCreationInputTokens: 18_434,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
{ getContextUsage },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("turn");
|
||||
|
||||
expect(getContextUsage).not.toHaveBeenCalled();
|
||||
expect(result.usage).toEqual({
|
||||
inputTokens: 4,
|
||||
cachedInputTokens: 16_999,
|
||||
outputTokens: 171,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 17_261,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the active result iteration when current and stream usage are unavailable", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
@@ -1842,6 +1950,165 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("manual compact boundary updates context usage from post tokens", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createMessageDeltaEvent(25),
|
||||
createCompactBoundary(),
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "/compact");
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("zero-token stream events after compact keep post-token usage", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createMessageDeltaEvent(25),
|
||||
createCompactBoundary(),
|
||||
createMessageStartEvent({
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
createMessageDeltaEvent(0),
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "/compact");
|
||||
|
||||
expect(
|
||||
events.filter(
|
||||
(event) => event.type === "usage_updated" && event.usage.contextWindowUsedTokens === 0,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("starting a new turn clears interrupted compact usage", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
|
||||
createCompactBoundary(),
|
||||
);
|
||||
expect(compactEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = await collectStreamEvents(session, "next turn");
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "turn_completed" && event.usage.contextWindowUsedTokens !== undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type PermissionResult,
|
||||
type PermissionUpdate,
|
||||
type Query,
|
||||
type SDKControlGetContextUsageResponse,
|
||||
type SDKMessage,
|
||||
type SDKPartialAssistantMessage,
|
||||
type SDKResultMessage,
|
||||
@@ -36,10 +35,8 @@ import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-defi
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "../diagnostic-utils.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
@@ -59,7 +56,6 @@ import {
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
@@ -76,12 +72,13 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModelsOptions,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import {
|
||||
@@ -1421,9 +1418,10 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
|
||||
return await getClaudeModelsWithSettings(this.logger, this.configDir);
|
||||
const models = await getClaudeModelsWithSettings(this.logger, this.configDir);
|
||||
return { models, modes: DEFAULT_MODES };
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
@@ -1477,28 +1475,9 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
defaultBinary: "claude",
|
||||
});
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
const auth = available
|
||||
const auth = availability.available
|
||||
? await resolveClaudeAuth(launch, availability, this.runtimeSettings)
|
||||
: null;
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels({
|
||||
cwd: os.homedir(),
|
||||
force: false,
|
||||
});
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||
@@ -1507,8 +1486,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
...(auth ? [{ label: "Auth", value: auth }] : []),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1704,31 +1681,6 @@ function readLegacyResultUsageTokens(usage: unknown): number | undefined {
|
||||
return usageRecord ? readUsageTokenTotal(usageRecord) : undefined;
|
||||
}
|
||||
|
||||
interface ClaudeCurrentContextUsage {
|
||||
totalTokens: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
function readCurrentContextUsage(
|
||||
value: SDKControlGetContextUsageResponse | unknown,
|
||||
): ClaudeCurrentContextUsage | undefined {
|
||||
const record = toObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const totalTokens = record.totalTokens;
|
||||
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const maxTokens = record.maxTokens;
|
||||
return {
|
||||
totalTokens,
|
||||
...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
|
||||
? { maxTokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isClaudeSubagentToolName(name: string | undefined): boolean {
|
||||
return name === "Task" || name === "Agent";
|
||||
}
|
||||
@@ -1737,6 +1689,7 @@ class ClaudeContextUsageState {
|
||||
private contextWindowMaxTokens: number | undefined;
|
||||
private streamRequestInputTokens: number | undefined;
|
||||
private streamRequestOutputTokens: number | undefined;
|
||||
private compactedContextWindowUsedTokens: number | undefined;
|
||||
private completedResultTurns = 0;
|
||||
|
||||
constructor(initialContextWindowMaxTokens?: number) {
|
||||
@@ -1746,6 +1699,7 @@ class ClaudeContextUsageState {
|
||||
beginTurn(): void {
|
||||
this.streamRequestInputTokens = undefined;
|
||||
this.streamRequestOutputTokens = undefined;
|
||||
this.compactedContextWindowUsedTokens = undefined;
|
||||
}
|
||||
|
||||
setInitialContextWindowMaxTokens(contextWindowMaxTokens: number | undefined): void {
|
||||
@@ -1760,12 +1714,6 @@ class ClaudeContextUsageState {
|
||||
return this.contextWindowMaxTokens;
|
||||
}
|
||||
|
||||
recordCurrentContextUsage(usage: ClaudeCurrentContextUsage | undefined): void {
|
||||
if (usage?.maxTokens !== undefined) {
|
||||
this.contextWindowMaxTokens = usage.maxTokens;
|
||||
}
|
||||
}
|
||||
|
||||
buildStreamUsageEvent(event: unknown): AgentStreamEvent | null {
|
||||
const streamEvent = toObjectRecord(event);
|
||||
if (!streamEvent) {
|
||||
@@ -1796,11 +1744,7 @@ class ClaudeContextUsageState {
|
||||
return this.createUsageUpdatedEvent(usedTokens);
|
||||
}
|
||||
|
||||
buildResultUsage(
|
||||
message: SDKResultMessage,
|
||||
modelUsage: unknown,
|
||||
currentContextUsage: ClaudeCurrentContextUsage | undefined,
|
||||
): AgentUsage | undefined {
|
||||
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
|
||||
try {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
@@ -1813,7 +1757,6 @@ class ClaudeContextUsageState {
|
||||
};
|
||||
|
||||
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
|
||||
this.recordCurrentContextUsage(currentContextUsage);
|
||||
if (this.contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
|
||||
} else if (modelContextWindowMaxTokens !== undefined) {
|
||||
@@ -1824,12 +1767,13 @@ class ClaudeContextUsageState {
|
||||
readActiveUsageTokens(message.usage) ??
|
||||
(this.completedResultTurns === 0 ? readLegacyResultUsageTokens(message.usage) : undefined);
|
||||
const usedTokens =
|
||||
currentContextUsage?.totalTokens ?? this.streamUsedTokens() ?? activeResultUsageTokens;
|
||||
this.streamUsedTokens() ?? activeResultUsageTokens ?? this.compactedContextWindowUsedTokens;
|
||||
if (usedTokens !== undefined) {
|
||||
usage.contextWindowUsedTokens = usedTokens;
|
||||
}
|
||||
return usage;
|
||||
} finally {
|
||||
this.compactedContextWindowUsedTokens = undefined;
|
||||
this.completedResultTurns += 1;
|
||||
}
|
||||
}
|
||||
@@ -1841,7 +1785,8 @@ class ClaudeContextUsageState {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.streamRequestInputTokens + this.streamRequestOutputTokens;
|
||||
const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens;
|
||||
return usedTokens > 0 ? usedTokens : undefined;
|
||||
}
|
||||
|
||||
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
|
||||
@@ -1857,6 +1802,24 @@ class ClaudeContextUsageState {
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
|
||||
this.streamRequestInputTokens = undefined;
|
||||
this.streamRequestOutputTokens = undefined;
|
||||
this.compactedContextWindowUsedTokens = postTokens;
|
||||
const usage: AgentUsage = {};
|
||||
if (this.contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
|
||||
}
|
||||
if (postTokens !== undefined) {
|
||||
usage.contextWindowUsedTokens = postTokens;
|
||||
}
|
||||
return {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeAgentSession implements AgentSession {
|
||||
@@ -3304,7 +3267,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message, activeQuery);
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
@@ -3380,7 +3343,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private async routeSdkMessageFromPump(message: SDKMessage, activeQuery: Query): Promise<void> {
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.shouldSuppressStaleResult(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -3410,12 +3373,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const events = await this.buildPumpedMessageEvents(
|
||||
message,
|
||||
activeQuery,
|
||||
identifiers.messageId,
|
||||
turnId,
|
||||
);
|
||||
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -3452,18 +3410,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private async buildPumpedMessageEvents(
|
||||
message: SDKMessage,
|
||||
activeQuery: Query,
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const currentContextUsage =
|
||||
message.type === "result" && message.subtype === "success"
|
||||
? await this.queryCurrentContextUsage(activeQuery)
|
||||
: undefined;
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
currentContextUsage,
|
||||
});
|
||||
const assistantTimelineEvents = this.timelineAssembler
|
||||
.consume({
|
||||
@@ -3483,18 +3435,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
private async queryCurrentContextUsage(
|
||||
activeQuery: Query,
|
||||
): Promise<ClaudeCurrentContextUsage | undefined> {
|
||||
try {
|
||||
const usage = await withTimeout(activeQuery.getContextUsage(), 3_000, "timeout");
|
||||
return readCurrentContextUsage(usage);
|
||||
} catch (error) {
|
||||
this.logger.debug({ err: error }, "Claude context usage query failed");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingResumedConversation(
|
||||
message: SDKMessage,
|
||||
activeQuery: Query,
|
||||
@@ -3562,7 +3502,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
currentContextUsage?: ClaudeCurrentContextUsage;
|
||||
},
|
||||
): AgentStreamEvent[] {
|
||||
const parentToolUseId =
|
||||
@@ -3613,9 +3552,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.appendStreamEventEvents(message, events, options);
|
||||
break;
|
||||
case "result":
|
||||
this.appendResultEvents(message, events, {
|
||||
currentContextUsage: options?.currentContextUsage,
|
||||
});
|
||||
this.appendResultEvents(message, events);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -3689,6 +3626,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
},
|
||||
provider: "claude",
|
||||
});
|
||||
events.push(this.contextUsage.buildCompactionUsageEvent(compactMetadata?.postTokens));
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
@@ -3816,9 +3754,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private appendResultEvents(
|
||||
message: Extract<SDKMessage, { type: "result" }>,
|
||||
events: AgentStreamEvent[],
|
||||
options?: { currentContextUsage?: ClaudeCurrentContextUsage },
|
||||
): void {
|
||||
const usage = this.convertUsage(message, message.modelUsage, options?.currentContextUsage);
|
||||
const usage = this.convertUsage(message, message.modelUsage);
|
||||
if (message.subtype === "success") {
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
// run client-side in the Claude CLI with no model turn — output_tokens
|
||||
@@ -3985,12 +3922,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(
|
||||
message: SDKResultMessage,
|
||||
modelUsage?: unknown,
|
||||
currentContextUsage?: ClaudeCurrentContextUsage,
|
||||
): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, modelUsage, currentContextUsage);
|
||||
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, modelUsage);
|
||||
}
|
||||
|
||||
private handlePermissionRequest: CanUseTool = async (
|
||||
@@ -4891,7 +4824,9 @@ function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
|
||||
return type.includes("tool");
|
||||
}
|
||||
|
||||
function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?: number } | null {
|
||||
function readCompactionMetadata(
|
||||
source: unknown,
|
||||
): { trigger?: string; preTokens?: number; postTokens?: number } | null {
|
||||
const sourceRecord = toObjectRecord(source);
|
||||
if (!sourceRecord) {
|
||||
return null;
|
||||
@@ -4909,7 +4844,9 @@ function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?
|
||||
const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined;
|
||||
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
|
||||
const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined;
|
||||
return { trigger, preTokens };
|
||||
const postTokensRaw = metadata.postTokens ?? metadata.post_tokens;
|
||||
const postTokens = typeof postTokensRaw === "number" ? postTokensRaw : undefined;
|
||||
return { trigger, preTokens, postTokens };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("getClaudeModels", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
it("appends concrete models from Claude settings.json", async () => {
|
||||
const configDir = await createClaudeConfigDir({
|
||||
model: "us.anthropic.claude-opus-4-7[1m]",
|
||||
@@ -78,7 +78,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
|
||||
expect(models).toEqual([
|
||||
...getClaudeModels(),
|
||||
@@ -127,7 +127,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -153,7 +153,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
...getClaudeModels().map((model) => model.id),
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => {
|
||||
test("lists models and runs a simple prompt", async () => {
|
||||
const client = createRealProviderClient("codex", createTestLogger());
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
|
||||
const models = await client.listModels({ cwd, force: false });
|
||||
const { models } = await client.fetchCatalog({ cwd, force: false });
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
const session = await client.createSession({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("listModels rejects gracefully when the codex binary does not exist", async () => {
|
||||
test("fetchCatalog rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
@@ -21,7 +21,9 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listModels({ cwd: "/tmp/codex-models", force: false })).rejects.toThrow();
|
||||
await expect(
|
||||
client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }),
|
||||
).rejects.toThrow();
|
||||
// Drain microtask queue to ensure no deferred uncaught errors
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
|
||||
@@ -26,15 +26,15 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type ToolCallTimelineItem,
|
||||
type AgentUsage,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModelsOptions,
|
||||
type ProviderCatalog,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -84,13 +84,11 @@ import {
|
||||
} from "./provider-image-output.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
|
||||
@@ -5561,7 +5559,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const models = await this.fetchModelsFromAppServer();
|
||||
return { models, modes: CODEX_MODES };
|
||||
}
|
||||
|
||||
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
|
||||
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger);
|
||||
@@ -5645,34 +5648,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
try {
|
||||
const launch = await resolveCodexLaunch(this.runtimeSettings);
|
||||
const availability = await checkCodexLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
const entries: Array<{ label: string; value: string }> = [
|
||||
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||
knownBinaryNames: ["codex"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
];
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (!available) {
|
||||
entries.push({ label: "Models", value: "Not checked" });
|
||||
} else {
|
||||
try {
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
entries.push({ label: "Models", value: String(models.length) });
|
||||
} catch (error) {
|
||||
entries.push({
|
||||
label: "Models",
|
||||
value: `Error - ${toDiagnosticErrorMessage(error)}`,
|
||||
});
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ label: "Status", value: status });
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Codex", entries),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
@@ -10,18 +9,17 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPConfigFeatureOption,
|
||||
type ACPBeforeModeWriteResult,
|
||||
type ACPProviderModeWriteResult,
|
||||
type ACPProviderModeWriterContext,
|
||||
type SessionStateResponse,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -47,6 +45,16 @@ const COPILOT_ALLOW_ALL_ON = "on";
|
||||
const COPILOT_ALLOW_ALL_OFF = "off";
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
||||
|
||||
export const COPILOT_AGENT_FEATURE_OPTION: ACPConfigFeatureOption = {
|
||||
id: "agent",
|
||||
configId: "agent",
|
||||
category: "_agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
emptyOptionLabel: "Default",
|
||||
};
|
||||
|
||||
export const COPILOT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: COPILOT_AGENT_MODE_ID,
|
||||
@@ -80,6 +88,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
@@ -98,33 +107,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
defaultBinary: "copilot",
|
||||
});
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes({ cwd: homedir(), force: false });
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Copilot", [
|
||||
@@ -132,8 +114,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
knownBinaryNames: ["copilot"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -45,17 +45,20 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
configOptions: [],
|
||||
});
|
||||
|
||||
await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([
|
||||
{
|
||||
provider: "acp",
|
||||
id: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
|
||||
label: "gpt-5.4",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
]);
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
provider: "acp",
|
||||
id: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
|
||||
label: "gpt-5.4",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not fall back to cursor-agent models when ACP reports zero models", async () => {
|
||||
@@ -65,6 +68,9 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
configOptions: [],
|
||||
});
|
||||
|
||||
await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]);
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import type { SpawnedACPProcess } from "./acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient diagnostics", () => {
|
||||
test("probes npx-backed agent packages instead of npx itself", () => {
|
||||
@@ -17,45 +16,8 @@ describe("GenericACPAgentClient diagnostics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("reports command, binary, ACP initialize, session, models, and modes", async () => {
|
||||
class TestGenericACPAgentClient extends GenericACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
initialize: {
|
||||
protocolVersion: 1,
|
||||
agentInfo: { name: "Cursor Agent", version: "2026.05.09" },
|
||||
agentCapabilities: {},
|
||||
},
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
models: {
|
||||
currentModelId: "composer-2[fast=true]",
|
||||
availableModels: [
|
||||
{
|
||||
modelId: "composer-2[fast=true]",
|
||||
name: "Composer 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
modes: {
|
||||
currentModeId: "ask",
|
||||
availableModes: [
|
||||
{ id: "agent", name: "Agent" },
|
||||
{ id: "ask", name: "Ask" },
|
||||
],
|
||||
},
|
||||
configOptions: [],
|
||||
}),
|
||||
},
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new TestGenericACPAgentClient({
|
||||
test("reports command, binary, and version command without spawning ACP", async () => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [process.execPath, "acp"],
|
||||
providerId: "cursor",
|
||||
@@ -69,88 +31,10 @@ describe("GenericACPAgentClient diagnostics", () => {
|
||||
expect(diagnostic).toContain(`Configured command: ${process.execPath} acp`);
|
||||
expect(diagnostic).toContain(`Launcher binary: ${process.execPath}`);
|
||||
expect(diagnostic).toContain(`Version command: ${process.execPath} --version`);
|
||||
expect(diagnostic).toContain("ACP initialize: ok (protocol 1, Cursor Agent 2026.05.09)");
|
||||
expect(diagnostic).toContain("ACP session/new: ok (session-1)");
|
||||
expect(diagnostic).toContain("Models: 1");
|
||||
expect(diagnostic).toContain("Modes: Agent, Ask");
|
||||
expect(diagnostic).toContain("Status: Available");
|
||||
});
|
||||
|
||||
test("counts models and modes exposed as ACP config options", async () => {
|
||||
class ConfigOptionGenericACPAgentClient extends GenericACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
initialize: {
|
||||
protocolVersion: 1,
|
||||
agentInfo: { name: "Devin", version: "2026.5.6" },
|
||||
agentCapabilities: {},
|
||||
},
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
models: null,
|
||||
modes: null,
|
||||
configOptions: [
|
||||
{
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "ask",
|
||||
options: [
|
||||
{ value: "accept-edits", name: "Code" },
|
||||
{ value: "ask", name: "Ask" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "swe-1-6-slow",
|
||||
options: [{ value: "swe-1-6-slow", name: "SWE-1.6 Slow" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new ConfigOptionGenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [process.execPath, "acp"],
|
||||
providerId: "devin",
|
||||
label: "Devin",
|
||||
});
|
||||
|
||||
const { diagnostic } = await client.getDiagnostic();
|
||||
|
||||
expect(diagnostic).toContain("Models: 1");
|
||||
expect(diagnostic).toContain("Modes: Code, Ask");
|
||||
});
|
||||
|
||||
test("reports ACP probe failures instead of falling back to no diagnostic", async () => {
|
||||
class FailingGenericACPAgentClient extends GenericACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
throw new Error("initialize timed out");
|
||||
}
|
||||
}
|
||||
|
||||
const client = new FailingGenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [process.execPath, "acp"],
|
||||
providerId: "cursor",
|
||||
label: "Cursor",
|
||||
});
|
||||
|
||||
const { diagnostic } = await client.getDiagnostic();
|
||||
|
||||
expect(diagnostic).toContain("Cursor (ACP)");
|
||||
expect(diagnostic).toContain("ACP initialize: Error - initialize timed out");
|
||||
expect(diagnostic).toContain("Status: Error (ACP probe failed: initialize timed out)");
|
||||
expect(diagnostic).not.toContain("ACP initialize");
|
||||
expect(diagnostic).not.toContain("ACP session/new");
|
||||
expect(diagnostic).not.toContain("Models:");
|
||||
expect(diagnostic).not.toContain("Modes:");
|
||||
expect(diagnostic).not.toContain("Status:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { homedir } from "node:os";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentProvider } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
|
||||
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
DEFAULT_ACP_CAPABILITIES,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
type SessionStateResponse,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000;
|
||||
const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const GenericACPProviderParamsSchema = z
|
||||
.object({
|
||||
supportsMcpServers: z.boolean().optional(),
|
||||
@@ -83,17 +71,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
try {
|
||||
const launch = await this.resolveConfiguredLaunch();
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
const versionProbe = buildVersionProbeCommand(this.command);
|
||||
const probeResult = available
|
||||
? await this.runDiagnosticACPProbe()
|
||||
: {
|
||||
status: formatDiagnosticStatus(false),
|
||||
initialize: "Not checked",
|
||||
session: "Not checked",
|
||||
models: "Not checked",
|
||||
modes: "Not checked",
|
||||
};
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic(providerName, [
|
||||
@@ -111,11 +89,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
label: "Version command",
|
||||
value: formatCommand(versionProbe.command, versionProbe.args),
|
||||
},
|
||||
{ label: "ACP initialize", value: probeResult.initialize },
|
||||
{ label: "ACP session/new", value: probeResult.session },
|
||||
{ label: "Models", value: probeResult.models },
|
||||
{ label: "Modes", value: probeResult.modes },
|
||||
{ label: "Status", value: probeResult.status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -131,58 +104,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
defaultBinary: this.command[0],
|
||||
});
|
||||
}
|
||||
|
||||
private async runDiagnosticACPProbe(): Promise<ACPDiagnosticProbeResult> {
|
||||
let initializeValue = "Not checked";
|
||||
let sessionValue = "Not checked";
|
||||
|
||||
try {
|
||||
const probe = await this.spawnProcess(
|
||||
{
|
||||
NO_BROWSER: "true",
|
||||
NO_OPEN_BROWSER: "1",
|
||||
GEMINI_CLI_NO_BROWSER: "true",
|
||||
CI: "1",
|
||||
},
|
||||
{
|
||||
initializeTimeoutMs: ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
try {
|
||||
initializeValue = formatInitializeResult(probe.initialize);
|
||||
const response = await withTimeout(
|
||||
probe.connection.newSession({
|
||||
cwd: homedir(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS,
|
||||
"ACP session/new",
|
||||
);
|
||||
sessionValue = response.sessionId ? `ok (${response.sessionId})` : "ok";
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return {
|
||||
status: formatDiagnosticStatus(true),
|
||||
initialize: initializeValue,
|
||||
session: sessionValue,
|
||||
...summarizeSessionState(this.provider, transformed),
|
||||
};
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: formatDiagnosticStatus(true, {
|
||||
source: "ACP probe",
|
||||
cause: error,
|
||||
}),
|
||||
initialize: formatProbeError(initializeValue, error),
|
||||
session:
|
||||
initializeValue === "Not checked" ? "Not checked" : formatProbeError(sessionValue, error),
|
||||
models: "Not checked",
|
||||
modes: "Not checked",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {
|
||||
@@ -197,14 +118,6 @@ function parseGenericACPProviderParams(params: unknown): GenericACPProviderParam
|
||||
return GenericACPProviderParamsSchema.parse(params ?? {});
|
||||
}
|
||||
|
||||
interface ACPDiagnosticProbeResult {
|
||||
status: string;
|
||||
initialize: string;
|
||||
session: string;
|
||||
models: string;
|
||||
modes: string;
|
||||
}
|
||||
|
||||
export interface CommandInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
@@ -271,60 +184,3 @@ function takePackageSpecPrefix(args: string[]): string[] {
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function formatInitializeResult(initialize: {
|
||||
protocolVersion: number;
|
||||
agentInfo?: unknown;
|
||||
}): string {
|
||||
const agentInfo = isAgentInfo(initialize.agentInfo)
|
||||
? `${initialize.agentInfo.name}${initialize.agentInfo.version ? ` ${initialize.agentInfo.version}` : ""}`
|
||||
: "ok";
|
||||
return `ok (protocol ${initialize.protocolVersion}, ${agentInfo})`;
|
||||
}
|
||||
|
||||
function isAgentInfo(value: unknown): value is { name: string; version?: string } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"name" in value &&
|
||||
typeof Reflect.get(value, "name") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeSessionState(
|
||||
provider: AgentProvider,
|
||||
response: SessionStateResponse,
|
||||
): Pick<ACPDiagnosticProbeResult, "models" | "modes"> {
|
||||
const models = deriveModelDefinitionsFromACP(provider, response.models, response.configOptions);
|
||||
const { modes } = deriveModesFromACP([], response.modes, response.configOptions);
|
||||
return {
|
||||
models: `${models.length}`,
|
||||
modes:
|
||||
modes.length > 0 ? modes.map((mode) => mode.label || mode.id).join(", ") : "none reported",
|
||||
};
|
||||
}
|
||||
|
||||
function formatProbeError(currentValue: string, error: unknown): string {
|
||||
if (currentValue !== "Not checked") {
|
||||
return currentValue;
|
||||
}
|
||||
return `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
test("default model is a five minute foreground stream with token-rate intervals", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
|
||||
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/mock-models", force: false });
|
||||
|
||||
expect(models[0]).toMatchObject({
|
||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||
|
||||
@@ -20,11 +20,11 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
FetchCatalogOptions,
|
||||
ImportableProviderSession,
|
||||
ImportProviderSessionContext,
|
||||
ImportProviderSessionInput,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ProviderCatalog,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -531,12 +531,11 @@ export class MockLoadTestAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return MODELS;
|
||||
}
|
||||
|
||||
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: MODELS,
|
||||
modes: getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes,
|
||||
};
|
||||
}
|
||||
|
||||
async listImportableSessions(): Promise<ImportableProviderSession[]> {
|
||||
|
||||
@@ -2,14 +2,12 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
FetchCatalogOptions,
|
||||
ProviderCatalog,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
export const MOCK_SLOW_PROVIDER_ID = "mock-slow";
|
||||
@@ -38,18 +36,14 @@ export class MockSlowProviderClient implements AgentClient {
|
||||
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
|
||||
}
|
||||
|
||||
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return neverResolves<AgentModelDefinition[]>();
|
||||
}
|
||||
|
||||
listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||
return neverResolves<AgentMode[]>();
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
return neverResolves<ProviderCatalog>();
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
return {
|
||||
diagnostic:
|
||||
"Mock slow provider: dev-only. listModels() never resolves so the snapshot manager will time out.",
|
||||
"Mock slow provider: dev-only. fetchCatalog() never resolves so the snapshot manager will time out.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
@@ -15,10 +15,16 @@ interface MockOpenCodeClientOptions {
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
|
||||
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", source: "env", models: {} }],
|
||||
},
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
return { openCodeClient, runtime };
|
||||
@@ -71,8 +77,11 @@ describe("OpenCode auto_accept feature", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
|
||||
});
|
||||
@@ -80,8 +89,11 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("falls back to default OpenCode modes when discovery returns no modes", async () => {
|
||||
const { runtime } = mockOpenCodeClient({ agents: [] });
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
|
||||
});
|
||||
@@ -89,7 +101,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("lists auto accept as a provider feature", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const enabledFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -115,7 +130,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -245,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -273,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -316,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -14,7 +14,7 @@ afterEach(() => {
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListImplementation = () =>
|
||||
new Promise((resolve) => {
|
||||
@@ -40,23 +40,28 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
});
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await expect(modelsPromise).resolves.toMatchObject([
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "zai/glm-5.1",
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
await expect(modelsPromise).resolves.toMatchObject({
|
||||
models: [
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "zai/glm-5.1",
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(openCodeClient.calls.providerList).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
test("uses a new server for explicit catalog refresh", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -66,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
|
||||
});
|
||||
|
||||
test("includes models from api-source providers not in connected", async () => {
|
||||
// Providers with source "api" are managed by the OpenCode console/subscription.
|
||||
// They don't appear in `connected` but are fully usable.
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -98,8 +106,11 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
expect(models).toMatchObject([
|
||||
{
|
||||
@@ -111,7 +122,7 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
});
|
||||
|
||||
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -130,15 +141,18 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
|
||||
"OpenCode has no connected providers",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not throw when only api-source providers are present with no connected providers", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -157,9 +171,20 @@ test("does not throw when only api-source providers are present with no connecte
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listModels({ cwd: "/tmp/opencode-models", force: false }),
|
||||
).resolves.toHaveLength(1);
|
||||
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
|
||||
).resolves.toMatchObject({
|
||||
models: [
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "pi/pi-model-1",
|
||||
label: "Pi Model 1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function mockOpenCodeClient(events: unknown[]) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = events;
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
|
||||
describe("OpenCode permission actions", () => {
|
||||
test("allow always sends OpenCode's always reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
|
||||
|
||||
test("plain allow keeps the backward-compatible once reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
@@ -49,11 +52,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
@@ -70,7 +76,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
})();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
@@ -101,7 +110,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandEvents = [];
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
expect(typeof session.id).toBe("string");
|
||||
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("single turn completes with streaming deltas", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const iterator = streamSession(session, "Say hello");
|
||||
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("manual compact hides the generated summary text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
@@ -263,8 +272,8 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 120_000);
|
||||
|
||||
test("listModels returns models with required fields", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
test("fetchCatalog returns models with required fields", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -286,15 +295,27 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
openCodeClient.appAgentsResponse = {
|
||||
data: [
|
||||
{
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const cwd = os.homedir();
|
||||
const models = await client.listModels({ cwd, force: false });
|
||||
const catalog = await client.fetchCatalog({ cwd, force: false });
|
||||
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models).toHaveLength(1);
|
||||
expect(Array.isArray(catalog.models)).toBe(true);
|
||||
expect(catalog.models).toHaveLength(1);
|
||||
|
||||
for (const model of models) {
|
||||
for (const model of catalog.models) {
|
||||
expect(model.provider).toBe("opencode");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
@@ -309,7 +330,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
});
|
||||
expect(typeof model.metadata?.contextWindowMaxTokens).toBe("number");
|
||||
}
|
||||
expect(models[0]).toMatchObject({
|
||||
expect(catalog.models[0]).toMatchObject({
|
||||
id: TEST_MODEL,
|
||||
label: "Big Pickle",
|
||||
metadata: {
|
||||
@@ -322,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}, 60_000);
|
||||
|
||||
test("limits concurrent OpenCode metadata requests across clients", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
let activeProviderListCalls = 0;
|
||||
let maxActiveProviderListCalls = 0;
|
||||
const response = {
|
||||
@@ -355,10 +376,13 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
}
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
client.listModels({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
|
||||
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -367,9 +391,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("available modes include build and plan", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -383,7 +410,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("custom agents defined in opencode.json appear in available modes", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = {
|
||||
data: [
|
||||
@@ -399,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -422,7 +452,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const planOpenCodeClient = new TestOpenCodeClient();
|
||||
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
|
||||
const buildOpenCodeClient = new TestOpenCodeClient();
|
||||
@@ -459,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
];
|
||||
runtime.enqueueClient(planOpenCodeClient);
|
||||
runtime.enqueueClient(buildOpenCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
const planSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
@@ -852,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -892,7 +928,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.mcpAddResponse = {
|
||||
data: {
|
||||
@@ -904,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -1656,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
|
||||
describe("OpenCodeAgentClient env", () => {
|
||||
test("passes launch-context env to env-specific server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
@@ -1677,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
|
||||
await session.close();
|
||||
|
||||
expect(runtime.acquisitions[0]).toMatchObject({
|
||||
force: false,
|
||||
kind: "dedicated",
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
@@ -1839,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions returns rows without hydrating session messages", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
const otherCwd = "/workspace/other";
|
||||
@@ -1936,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -1956,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("importSession reads only the selected OpenCode session without listing", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const metadataClient = new TestOpenCodeClient();
|
||||
const resumedClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
@@ -1995,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
runtime.enqueueClient(metadataClient);
|
||||
runtime.enqueueClient(resumedClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const imported = await client.importSession(
|
||||
{ providerHandleId: "ses_selected", cwd },
|
||||
{
|
||||
@@ -2032,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const requestedCwd = "C:/Users/Administrator/GhostFactory";
|
||||
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
|
||||
@@ -2055,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
type GlobalSession as OpenCodeGlobalSession,
|
||||
type Message as OpenCodeMessage,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
type Part as OpenCodePart,
|
||||
type Session as OpenCodeSession,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
@@ -38,15 +39,15 @@ import {
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ResolveAgentCreateConfigInput,
|
||||
type ResolveAgentCreateConfigResult,
|
||||
type ListModelsOptions,
|
||||
type ListModesOptions,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -65,9 +66,11 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
OpenCodeServerManager,
|
||||
type OpenCodeServerManagerLike,
|
||||
} from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
@@ -77,11 +80,6 @@ import {
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
|
||||
@@ -1214,31 +1212,15 @@ export const __openCodeInternals = {
|
||||
};
|
||||
|
||||
interface OpenCodeAgentClientDeps {
|
||||
runtime?: OpenCodeRuntime;
|
||||
serverManager?: OpenCodeServerManagerLike;
|
||||
createClient?: OpenCodeClientFactory;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
}
|
||||
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
|
||||
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
return this.serverManager.acquire(options);
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.serverManager.ensureRunning();
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createSdkOpenCodeClient(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
|
||||
export class OpenCodeAgentClient implements AgentClient {
|
||||
@@ -1247,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
|
||||
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
|
||||
|
||||
private readonly runtime: OpenCodeRuntime;
|
||||
private readonly serverManager: OpenCodeServerManagerLike;
|
||||
private readonly createOpenCodeClient: OpenCodeClientFactory;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
@@ -1259,13 +1242,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
}),
|
||||
);
|
||||
this.serverManager =
|
||||
deps.serverManager ??
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
});
|
||||
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1274,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
});
|
||||
const acquisition = launchContext?.env
|
||||
? await this.serverManager.acquireDedicated(launchContext.env)
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1336,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
cwd,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1362,101 +1343,20 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: options.cwd,
|
||||
});
|
||||
|
||||
try {
|
||||
// Background model discovery can be legitimately slow while OpenCode refreshes
|
||||
// provider state, so allow longer than turn execution paths.
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
withTimeout(
|
||||
client.provider.list({ directory: options.cwd }),
|
||||
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
|
||||
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
|
||||
const providers = response.data;
|
||||
if (!providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectedProviderIds = new Set(providers.connected);
|
||||
|
||||
// Providers with source "api" are managed by the OpenCode console/subscription (e.g. Pi
|
||||
// coding agent). They do not appear in `connected` (which only lists env/config providers)
|
||||
// but are fully usable — OpenCode authenticates them internally via the console session.
|
||||
const isAccessible = (provider: { id: string; source: string }): boolean =>
|
||||
connectedProviderIds.has(provider.id) || provider.source === "api";
|
||||
|
||||
// Fail fast if no providers are accessible at all
|
||||
if (!providers.all.some(isAccessible)) {
|
||||
throw new Error(
|
||||
"OpenCode has no connected providers. Please authenticate with at least one provider " +
|
||||
"(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " +
|
||||
"or log in to OpenCode Go via the console.",
|
||||
);
|
||||
}
|
||||
|
||||
const models: AgentModelDefinition[] = [];
|
||||
this.modelContextWindows.clear();
|
||||
for (const provider of providers.all) {
|
||||
if (!isAccessible(provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
this.modelContextWindows.set(
|
||||
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||
contextWindowMaxTokens,
|
||||
);
|
||||
}
|
||||
models.push(definition);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
} finally {
|
||||
acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const acquisition = options.force
|
||||
? await this.serverManager.acquireNew()
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = this.runtime.createClient({ baseUrl: url, directory });
|
||||
const client = this.createOpenCodeClient({ baseUrl: url, directory });
|
||||
|
||||
try {
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
withTimeout(
|
||||
client.app.agents({ directory }),
|
||||
10_000,
|
||||
"OpenCode app.agents timed out after 10s",
|
||||
),
|
||||
);
|
||||
|
||||
if (response.error || !response.data) {
|
||||
return DEFAULT_MODES;
|
||||
}
|
||||
|
||||
const discovered = response.data
|
||||
.filter(isSelectableOpenCodeAgent)
|
||||
.map(mapOpenCodeAgentToMode);
|
||||
|
||||
return mergeOpenCodeModes(discovered);
|
||||
const [models, modes] = await Promise.all([
|
||||
this.fetchModelsFromClient(client, directory),
|
||||
this.fetchModesFromClient(client, directory),
|
||||
]);
|
||||
return { models, modes };
|
||||
} finally {
|
||||
acquisition.release();
|
||||
}
|
||||
@@ -1464,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1485,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: options?.cwd ?? "",
|
||||
});
|
||||
@@ -1500,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: input.cwd,
|
||||
});
|
||||
@@ -1545,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.runtime.shutdown();
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
@@ -1555,17 +1455,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
defaultBinary: "opencode",
|
||||
});
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
let serverStatus = "Not running";
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
try {
|
||||
const { url } = await this.runtime.ensureServerRunning();
|
||||
serverStatus = `Running (${url})`;
|
||||
} catch (error) {
|
||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||
}
|
||||
|
||||
let authValue = "Not checked";
|
||||
const authCommand = availability.available
|
||||
@@ -1588,40 +1477,13 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes({ cwd: homedir(), force: false });
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("OpenCode", [
|
||||
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||
knownBinaryNames: ["opencode"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
{ label: "Server", value: serverStatus },
|
||||
{ label: "Auth", value: authValue },
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1630,6 +1492,83 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchModelsFromClient(
|
||||
client: OpencodeClient,
|
||||
directory: string,
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
withTimeout(
|
||||
client.provider.list({ directory }),
|
||||
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
|
||||
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
|
||||
const providers = response.data;
|
||||
if (!providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectedProviderIds = new Set(providers.connected);
|
||||
|
||||
const isAccessible = (provider: { id: string; source: string }): boolean =>
|
||||
connectedProviderIds.has(provider.id) || provider.source === "api";
|
||||
|
||||
if (!providers.all.some(isAccessible)) {
|
||||
throw new Error(
|
||||
"OpenCode has no connected providers. Please authenticate with at least one provider " +
|
||||
"(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " +
|
||||
"or log in to OpenCode Go via the console.",
|
||||
);
|
||||
}
|
||||
|
||||
const models: AgentModelDefinition[] = [];
|
||||
this.modelContextWindows.clear();
|
||||
for (const provider of providers.all) {
|
||||
if (!isAccessible(provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
this.modelContextWindows.set(
|
||||
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||
contextWindowMaxTokens,
|
||||
);
|
||||
}
|
||||
models.push(definition);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
private async fetchModesFromClient(
|
||||
client: OpencodeClient,
|
||||
directory: string,
|
||||
): Promise<AgentMode[]> {
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
withTimeout(
|
||||
client.app.agents({ directory }),
|
||||
10_000,
|
||||
"OpenCode app.agents timed out after 10s",
|
||||
),
|
||||
);
|
||||
|
||||
if (response.error || !response.data) {
|
||||
return DEFAULT_MODES;
|
||||
}
|
||||
|
||||
const discovered = response.data.filter(isSelectableOpenCodeAgent).map(mapOpenCodeAgentToMode);
|
||||
return mergeOpenCodeModes(discovered);
|
||||
}
|
||||
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
||||
if (config.provider !== "opencode") {
|
||||
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
type OpenCodeServerProcessSpawner,
|
||||
} from "./opencode/server-manager.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("OpenCodeServerManager generations", () => {
|
||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||
const { manager, runtime } = createTestManager([4101, 4102]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
|
||||
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
|
||||
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
|
||||
@@ -37,11 +41,11 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("new acquisitions after rotation use the new server", async () => {
|
||||
const { manager, runtime } = createTestManager([4201, 4202]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const rotatedAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const rotatedAcquisition = await manager.acquireNew();
|
||||
rotatedAcquisition.release();
|
||||
|
||||
const nextAcquisition = await manager.acquire({ force: false });
|
||||
const nextAcquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
|
||||
oldAcquisition.release();
|
||||
});
|
||||
|
||||
test("concurrent forced acquisitions share one fresh generation", async () => {
|
||||
test("concurrent new-server acquisitions share one fresh generation", async () => {
|
||||
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
|
||||
|
||||
const initialAcquisition = await manager.acquire({ force: false });
|
||||
const initialAcquisition = await manager.acquireCurrent();
|
||||
initialAcquisition.release();
|
||||
|
||||
const [modelsAcquisition, modesAcquisition] = await Promise.all([
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquireNew(),
|
||||
manager.acquireNew(),
|
||||
]);
|
||||
|
||||
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("release is idempotent", async () => {
|
||||
const { manager, runtime } = createTestManager([4301, 4302]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
newAcquisition.release();
|
||||
|
||||
oldAcquisition.release();
|
||||
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown kills current and retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4401, 4402]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquire({ force: true });
|
||||
await manager.acquireCurrent();
|
||||
await manager.acquireNew();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -96,7 +100,7 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
|
||||
const { manager, runtime } = createTestManager([4451]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
runtime.processForPort(4451).markKillSignalSent();
|
||||
|
||||
await manager.shutdown();
|
||||
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
|
||||
expect(runtime.terminatedPorts).toEqual([4451]);
|
||||
});
|
||||
|
||||
test("startup timeout kills the spawned server and removes its managed-process record", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { manager, runtime } = createTestManager([4471], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
const failure = expect(acquisition).rejects.toThrow("OpenCode server startup timeout");
|
||||
await runtime.settle();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await failure;
|
||||
expect(runtime.terminatedPorts).toEqual([4471]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("shutdown kills a server that is still starting", async () => {
|
||||
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
await expect(acquisition).rejects.toThrow("OpenCode server exited with code null");
|
||||
expect(runtime.terminatedPorts).toEqual([4472]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("dedicated server startup is protected from retired cleanup", async () => {
|
||||
const { manager, runtime } = createTestManager([4473, 4474], { autoAnnounce: false });
|
||||
|
||||
const currentStart = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
runtime.processForPort(4473).announceListening();
|
||||
const currentAcquisition = await currentStart;
|
||||
|
||||
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
|
||||
await runtime.settle();
|
||||
|
||||
currentAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
runtime.processForPort(4474).announceListening();
|
||||
const dedicatedAcquisition = await dedicatedStart;
|
||||
|
||||
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
|
||||
|
||||
dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4474]);
|
||||
});
|
||||
|
||||
test("repeated rotations leave zero unreferenced retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
|
||||
|
||||
const firstAcquisition = await manager.acquire({ force: false });
|
||||
const secondAcquisition = await manager.acquire({ force: true });
|
||||
const firstAcquisition = await manager.acquireCurrent();
|
||||
const secondAcquisition = await manager.acquireNew();
|
||||
secondAcquisition.release();
|
||||
const thirdAcquisition = await manager.acquire({ force: true });
|
||||
const thirdAcquisition = await manager.acquireNew();
|
||||
thirdAcquisition.release();
|
||||
firstAcquisition.release();
|
||||
|
||||
@@ -122,7 +177,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("records helper server starts and removes the record on process exit", async () => {
|
||||
const { manager, runtime } = createTestManager([4601]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
expect(await runtime.managedProcesses.list()).toEqual([
|
||||
{
|
||||
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("removes helper server records on shutdown", async () => {
|
||||
const { manager, runtime } = createTestManager([4602]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -155,11 +210,16 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createTestManager(ports: number[]): {
|
||||
function createTestManager(
|
||||
ports: number[],
|
||||
options: { autoAnnounce?: boolean } = {},
|
||||
): {
|
||||
manager: OpenCodeServerManager;
|
||||
runtime: FakeOpenCodeServerRuntime;
|
||||
} {
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports);
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports, {
|
||||
autoAnnounce: options.autoAnnounce ?? true,
|
||||
});
|
||||
return {
|
||||
manager: new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
|
||||
readonly managedProcesses = new FakeManagedProcesses();
|
||||
readonly terminatedPorts: number[] = [];
|
||||
private readonly ports: number[];
|
||||
private readonly autoAnnounce: boolean;
|
||||
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
|
||||
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
|
||||
|
||||
constructor(ports: number[]) {
|
||||
constructor(ports: number[], options: { autoAnnounce: boolean }) {
|
||||
this.ports = [...ports];
|
||||
this.autoAnnounce = options.autoAnnounce;
|
||||
}
|
||||
|
||||
get launchedPorts(): number[] {
|
||||
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
|
||||
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
|
||||
this.processesByChild.set(process.child, process);
|
||||
this.processesByPort.set(port, process);
|
||||
queueMicrotask(() => process.announceListening());
|
||||
if (this.autoAnnounce) {
|
||||
queueMicrotask(() => process.announceListening());
|
||||
}
|
||||
return process.child;
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user