Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e529c3601f fix(server): drop Claude permission skip allowance
Fixes #1582.
2026-06-20 13:45:42 +07:00
211 changed files with 7378 additions and 17130 deletions
-59
View File
@@ -1,64 +1,5 @@
# 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
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
- Add a project without creating a workspace
- Add a setting to show branch names instead of titles in the sidebar
### Improved
- Mid-turn thinking and mode changes now say they apply next turn
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
- A running agent's mode change is remembered for new agents
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
### Fixed
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
- Reopening an archived Codex agent no longer hangs
- Claude's context meter no longer jumps to subagent usage
- Claude's context meter fills from the first message in a new session
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
- Collapsed sidebar projects stay collapsed
## 0.1.97 - 2026-06-18
### Added
+11 -18
View File
@@ -14,23 +14,16 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -113,11 +106,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+1 -4
View File
@@ -27,9 +27,6 @@ $PASEO_HOME/
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Expo push notification tokens
```
@@ -54,7 +51,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
-32
View File
@@ -49,38 +49,6 @@ 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`.
+6 -19
View File
@@ -10,8 +10,6 @@ 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.
@@ -28,28 +26,18 @@ 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 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.
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.
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.
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.
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.
## Provider Helper Processes
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.
---
## Provider Snapshot Refresh Contract
@@ -79,8 +67,6 @@ To add plan usage for a provider, add `packages/server/src/services/quota-fetche
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
---
## ACP Provider Checklist
@@ -342,13 +328,14 @@ interface AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listImportableSessions(
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession(
importSession?(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
+1 -10
View File
@@ -13,15 +13,6 @@ 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).
@@ -126,7 +117,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) → 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.
**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.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
-1
View File
@@ -130,7 +130,6 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
## Agent authentication in tests
+1 -1
View File
@@ -1 +1 @@
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
+46 -72
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.100",
"version": "0.1.97",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.100",
"version": "0.1.97",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -11207,8 +11207,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
@@ -11221,8 +11220,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
@@ -11235,8 +11233,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
@@ -11249,8 +11246,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
@@ -11263,8 +11259,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
@@ -11277,8 +11272,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
@@ -11291,8 +11285,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
@@ -11305,8 +11298,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
@@ -11319,8 +11311,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
@@ -11333,8 +11324,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
@@ -11347,8 +11337,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
@@ -11361,8 +11350,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
@@ -11375,8 +11363,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
@@ -11389,8 +11376,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
@@ -11403,8 +11389,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
@@ -11417,8 +11402,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
@@ -11431,8 +11415,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
@@ -11445,8 +11428,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
@@ -11459,8 +11441,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
@@ -11473,8 +11454,7 @@
"optional": true,
"os": [
"openbsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
@@ -11487,8 +11467,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
@@ -11501,8 +11480,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
@@ -11515,8 +11493,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
@@ -11529,8 +11506,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
@@ -11543,8 +11519,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
@@ -18272,7 +18247,6 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -35243,7 +35217,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -35566,12 +35540,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/server": "0.1.100",
"@getpaseo/client": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/server": "0.1.97",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35817,10 +35791,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -35831,7 +35805,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.100",
"version": "0.1.97",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36074,7 +36048,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.100",
"version": "0.1.97",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -36970,7 +36944,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37201,7 +37175,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37213,7 +37187,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37431,15 +37405,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.100",
"@getpaseo/highlight": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@getpaseo/client": "0.1.97",
"@getpaseo/highlight": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37848,7 +37822,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.100",
"version": "0.1.97",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+6 -4
View File
@@ -12,7 +12,7 @@ import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
@@ -21,13 +21,14 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
@@ -38,7 +39,8 @@ test.describe("Agent stream UI", () => {
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
-17
View File
@@ -26,26 +26,15 @@ import {
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
let workspaceId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
workspaceId = created.workspace.id;
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
@@ -53,12 +42,10 @@ test.describe("Archive tab reconciliation", () => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -94,12 +81,10 @@ test.describe("Archive tab reconciliation", () => {
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -126,12 +111,10 @@ test.describe("Archive tab reconciliation", () => {
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
@@ -193,7 +193,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
@@ -205,7 +205,8 @@ test.describe("Composer attachments", () => {
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
@@ -213,7 +214,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
@@ -225,7 +226,8 @@ test.describe("Composer attachments", () => {
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
+4 -27
View File
@@ -228,7 +228,6 @@ 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);
@@ -239,9 +238,6 @@ 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({
@@ -274,13 +270,10 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
@@ -407,22 +400,6 @@ 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> {
@@ -4,7 +4,7 @@ import { gotoAppShell } from "./helpers/app";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -66,13 +66,6 @@ async function addProjectFromPicker(page: Page, projectPath: string): Promise<st
return testId!.replace("sidebar-project-row-", "");
}
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
await page
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout: 60_000 });
}
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
@@ -83,7 +76,7 @@ test.describe("Project with no workspaces persists", () => {
try {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await waitForNoProjectsInSidebar(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
@@ -99,7 +92,7 @@ test.describe("Project with no workspaces persists", () => {
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -170,7 +163,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarProjectListReady(page);
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readded = await workspace.client.addProject(workspace.repoPath);
@@ -186,6 +179,7 @@ test.describe("Project remove", () => {
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
await workspace.cleanup();
}
});
+13 -3
View File
@@ -35,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -52,14 +56,20 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -74,7 +84,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
};
}
+5 -21
View File
@@ -149,7 +149,6 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
@@ -167,29 +166,20 @@ export async function startRunningMockAgent(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: workspace.id,
workspaceId: createdWorkspace.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
return {
client,
repo,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
};
return { client, repo };
}
export interface GithubWorkspaceHandle {
@@ -208,14 +198,8 @@ export async function openGithubWorkspace(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, workspace.id);
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
await waitForTabBar(page);
return {
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
return { cleanup: () => client.close().catch(() => undefined) };
}
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "patchDaemonConfig"
| "removeProject"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
-3
View File
@@ -14,7 +14,6 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
projectId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -188,7 +187,6 @@ export async function launchAgent(input: {
page: input.page,
client,
agentId: agent.id,
projectId: createdWorkspace.workspace.projectId,
workspaceId: createdWorkspace.workspace.id,
cwd: input.cwd,
provider: input.provider,
@@ -198,7 +196,6 @@ export async function launchAgent(input: {
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.removeProject(handle.projectId).catch(() => undefined);
await handle.client.close().catch(() => undefined);
}
+5 -8
View File
@@ -30,7 +30,6 @@ export interface SeedDaemonClient {
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -200,17 +199,15 @@ export async function seedWorkspace(options: {
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
return {
client,
repoPath: project.path,
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceId: created.workspace.id,
workspaceName: created.workspace.name,
workspaceDirectory: created.workspace.workspaceDirectory,
projectId: created.workspace.projectId,
projectDisplayName: created.workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
-5
View File
@@ -28,18 +28,15 @@ function sleep(ms: number): Promise<void> {
export class TerminalE2EHarness {
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly projectId: string;
readonly workspaceId: string;
private constructor(input: {
client: SeedDaemonClient;
tempRepo: TempRepo;
projectId: string;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.projectId = input.projectId;
this.workspaceId = input.workspaceId;
}
@@ -57,13 +54,11 @@ export class TerminalE2EHarness {
return new TerminalE2EHarness({
client,
tempRepo,
projectId: seedResult.workspace.projectId,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.removeProject(this.projectId).catch(() => {});
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
@@ -37,7 +37,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const projectIds = new Set<string>();
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
@@ -65,7 +64,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
projectIds.add(added.project.projectId);
}
const created = await client.createWorkspace({
@@ -75,7 +73,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
}
const workspaceId = created.workspace.id;
projectIds.add(created.workspace.projectId);
return {
workspaceId,
@@ -91,11 +88,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
return {
withWorkspace,
cleanup: async () => {
if (client) {
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
@@ -19,7 +19,6 @@ type WorkspaceSetupDaemonClient = Pick<
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "removeProject"
| "subscribeRawMessages"
>;
@@ -1,220 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute } from "./helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
mock: {
model: "ten-second-stream",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
async function readCodexModePreference(page: Page): Promise<unknown> {
return page.evaluate((preferencesKey) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
providerPreferences?: Record<string, { mode?: unknown }>;
};
return parsed.providerPreferences?.codex?.mode ?? null;
}, CREATE_AGENT_PREFERENCES_KEY);
}
async function selectMode(page: Page, label: string): Promise<void> {
const modeControl = page.getByTestId("mode-control").first();
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
const option = page
.getByRole("dialog")
.last()
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
.first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
});
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
} finally {
await seeded.cleanup();
}
});
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Codex live mode preference e2e",
modeId: "auto",
model: "gpt-5.4-mini",
});
await openAgentRoute(page, {
workspaceId: seeded.workspaceId,
agentId: agent.id,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
} finally {
await seeded.cleanup();
}
});
});
+1 -1
View File
@@ -52,7 +52,7 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -54,12 +54,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
});
@@ -97,12 +95,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
});
+1 -1
View File
@@ -127,7 +127,7 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -34,7 +34,6 @@ test.describe("Workspace agent tab rename", () => {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: initialTitle,
});
@@ -72,7 +72,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
@@ -390,7 +390,7 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
@@ -204,7 +204,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
});
@@ -307,7 +306,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-refresh-route-${Date.now()}`,
});
await injectDesktopBridge(page, {
@@ -346,12 +344,10 @@ test.describe("Workspace navigation regression", () => {
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
});
@@ -18,7 +18,6 @@ test.describe("Workspace pane mounting", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `pane-remount-${Date.now()}`,
});
@@ -20,7 +20,6 @@ test.describe("Worktree restore after daemon restart", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ retries: 0, timeout: 180_000 });
@@ -35,10 +34,6 @@ test.describe("Worktree restore after daemon restart", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -54,18 +49,15 @@ test.describe("Worktree restore after daemon restart", () => {
// the History table cells must show after restore — never "main".
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: worktreeSlug,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+2 -13
View File
@@ -25,7 +25,6 @@ test.describe("Worktree restore", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
@@ -40,10 +39,6 @@ test.describe("Worktree restore", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -53,18 +48,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
@@ -98,18 +90,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.100",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {
+22 -22
View File
@@ -28,7 +28,6 @@ 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";
@@ -41,7 +40,6 @@ import {
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import {
SidebarAnimationProvider,
useSidebarAnimation,
@@ -56,7 +54,7 @@ import {
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
} from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -467,26 +465,14 @@ 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}>
{workspaceChrome}
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -877,6 +863,8 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
@@ -901,7 +889,19 @@ function RootStack() {
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
{/*
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="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
+6 -27
View File
@@ -1,16 +1,8 @@
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { resolveStartupRoute } from "@/app/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 />;
}
@@ -32,21 +24,8 @@ function KnownHostRoute() {
return <Redirect href={startupRoute.href} />;
}
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>;
// 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 />;
}
+6 -36
View File
@@ -1,39 +1,9 @@
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";
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
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,
}),
})}
/>
);
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
}
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -201,7 +200,6 @@ describe("resolveStartupRoute", () => {
hosts: [],
anyOnlineHostServerId: null,
workspaceSelection: null,
workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
@@ -257,22 +255,10 @@ 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({
@@ -358,45 +344,3 @@ 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,7 +136,6 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
route: IndexStartupRouteTarget;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
@@ -152,42 +151,6 @@ 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 === "";
}
@@ -208,16 +171,11 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
return { kind: "splash" };
}
if (
shouldRestoreWorkspaceSelection(input) &&
hostExists(input.hosts, input.workspaceSelection.serverId)
) {
const workspaceSelection = input.workspaceSelection;
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
return {
kind: "redirect",
href: buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
),
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
};
}
+1 -16
View File
@@ -2,12 +2,8 @@ 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,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
@@ -24,13 +20,6 @@ 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 },
@@ -39,10 +28,6 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
@@ -1,106 +0,0 @@
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,
});
});
});
@@ -1,59 +0,0 @@
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),
};
}
@@ -1,162 +0,0 @@
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;
@@ -9,25 +9,23 @@ import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number | null;
usedTokens: number | null;
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const SVG_SIZE = 16;
const COMPACT_SVG_SIZE = 14;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const RADIUS = 7;
const COMPACT_RADIUS = 6;
const STROKE_WIDTH = 2.25;
const COMPACT_STROKE_WIDTH = 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
@@ -102,7 +100,6 @@ export function ContextWindowMeter({
showPercentage = false,
serverId,
provider,
pending = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -111,8 +108,7 @@ export function ContextWindowMeter({
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const percentage = getUsagePercentage(maxTokens, usedTokens);
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
@@ -123,42 +119,14 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
getMeterGeometry(showPercentage);
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
@@ -262,14 +230,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
gap: theme.spacing[1],
minWidth: 200,
},
tooltipTitle: {
+122 -178
View File
@@ -7,6 +7,7 @@ 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";
@@ -15,13 +16,9 @@ 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,
@@ -31,9 +28,8 @@ 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 } from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -53,29 +49,7 @@ interface ExplorerSidebarProps {
onOpenFile?: (filePath: string) => void;
}
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({
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
@@ -83,22 +57,40 @@ export function CompactExplorerSidebar({
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
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 closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
enabled: isMobile,
});
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,
@@ -111,15 +103,23 @@ export function CompactExplorerSidebar({
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,
});
showMobileAgent();
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
},
[isOpen, showMobileAgent],
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
@@ -128,14 +128,24 @@ export function CompactExplorerSidebar({
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(true)
.enabled(enableSidebarCloseGesture)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
@@ -209,6 +219,7 @@ export function CompactExplorerSidebar({
isGesturing.value = false;
}),
[
enableSidebarCloseGesture,
windowWidth,
translateX,
backdropOpacity,
@@ -223,6 +234,32 @@ export function CompactExplorerSidebar({
],
);
// 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 }],
}));
@@ -231,6 +268,10 @@ export function CompactExplorerSidebar({
opacity: backdropOpacity.value,
}));
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo(
() => [
explorerStaticStyles.backdrop,
@@ -271,6 +312,10 @@ export function CompactExplorerSidebar({
],
[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.
@@ -279,101 +324,39 @@ export function CompactExplorerSidebar({
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
// 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;
}
<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>
);
}
if (isMobile) {
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={backdropCombinedStyle} />
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),
<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>
);
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;
}
@@ -381,11 +364,12 @@ 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>
<ExplorerSidebarContent
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
@@ -443,7 +427,7 @@ interface SidebarContentProps {
onOpenFile?: (filePath: string) => void;
}
function ExplorerSidebarContent({
function SidebarContent({
activeTab,
onTabPress,
onClose,
@@ -457,7 +441,6 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
@@ -467,17 +450,11 @@ function ExplorerSidebarContent({
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" && !showPrTab ? "changes" : requestedTab;
const resolvedTab: ExplorerTab =
requestedTab === "pr" && !hasPullRequest ? "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],
@@ -510,7 +487,7 @@ function ExplorerSidebarContent({
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
{isGit && showPrTab && (
{isGit && hasPullRequest && (
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
@@ -554,13 +531,12 @@ function ExplorerSidebarContent({
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "pr" && (
<PrTabContent
{resolvedTab === "pr" && prPane.data && (
<PullRequestPane
serverId={serverId}
cwd={workspaceRoot}
prPane={prPane}
data={prPane.data}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/>
)}
</View>
@@ -568,38 +544,6 @@ function ExplorerSidebarContent({
);
}
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.
+3 -3
View File
@@ -35,7 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -213,7 +213,7 @@ export const LeftSidebar = memo(function LeftSidebar({
enabled: isCompactLayout || isOpen,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
useSidebarShortcutModel({ projects, isInitialLoad });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
@@ -1096,7 +1096,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu serverId={serverId} />
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -600,20 +600,13 @@ export function ProviderDiagnosticSheet({
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
const currentModels = providerEntry?.models;
if (currentModels && currentModels.length > 0) {
stableDiscoveredRef.current = currentModels;
if (providerEntry?.models && providerEntry.models.length > 0) {
stableDiscoveredRef.current = providerEntry.models;
}
const discoveredModels = useMemo(() => {
if (currentModels && currentModels.length > 0) {
return currentModels;
}
if (providerSnapshotRefreshing) {
return stableDiscoveredRef.current;
}
return [];
}, [currentModels, providerSnapshotRefreshing]);
const discoveredModels =
providerEntry?.models && providerEntry.models.length > 0
? providerEntry.models
: stableDiscoveredRef.current;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
@@ -8,11 +8,9 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -22,25 +20,11 @@ const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "status", label: "Status" },
];
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
{ value: "title", label: "Title" },
{ value: "branch", label: "Branch name" },
];
interface DisplayPreferenceOption<Value extends string> {
value: Value;
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
(mode: SidebarGroupMode) => {
@@ -50,13 +34,6 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[serverId, setGroupMode],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -70,61 +47,45 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Display preferences"
testID="sidebar-display-preferences-menu"
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
<GroupModeMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
/>
))}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
</View>
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={workspaceTitleSource === item.value}
testIDPrefix="sidebar-workspace-title-source"
onSelect={handleWorkspaceTitleSourceSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function DisplayPreferenceMenuItem<Value extends string>({
function GroupModeMenuItem({
item,
isSelected,
testIDPrefix,
onSelect,
}: {
item: DisplayPreferenceOption<Value>;
item: { value: SidebarGroupMode; label: string };
isSelected: boolean;
testIDPrefix: string;
onSelect: (value: Value) => void;
onSelect: (mode: SidebarGroupMode) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`${testIDPrefix}-${item.value}`}
testID={`sidebar-grouping-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
<Text style={styles.optionLabel}>{item.label}</Text>
{item.label}
</DropdownMenuItem>
);
}
@@ -149,7 +110,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
optionLabel: {
fontSize: theme.fontSize.sm,
},
}));
@@ -22,14 +22,12 @@ import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -112,10 +110,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
showShortcutBadge?: boolean;
children?: ReactNode;
}) {
const {
settings: { workspaceTitleSource },
} = useAppSettings();
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
@@ -138,7 +132,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={styles.workspaceTitleRow}>
<View style={styles.workspaceTitleLeft}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
{workspace.name}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
describe("resolveSidebarWorkspacePrimaryLabel", () => {
it("uses the workspace name in title mode", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Investigate search", currentBranch: "fix/search" },
workspaceTitleSource: "title",
});
expect(label).toBe("Investigate search");
});
it("uses the branch name in branch mode", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Investigate search", currentBranch: "fix/search" },
workspaceTitleSource: "branch",
});
expect(label).toBe("fix/search");
});
it("falls back to the workspace name in branch mode without a branch", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Local folder", currentBranch: null },
workspaceTitleSource: "branch",
});
expect(label).toBe("Local folder");
});
});
@@ -1,12 +0,0 @@
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
export function resolveSidebarWorkspacePrimaryLabel(input: {
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
workspaceTitleSource: WorkspaceTitleSource;
}): string {
if (input.workspaceTitleSource === "branch") {
return input.workspace.currentBranch ?? input.workspace.name;
}
return input.workspace.name;
}
@@ -16,22 +16,14 @@ 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";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
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, 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 { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -113,10 +105,7 @@ 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("");
@@ -156,26 +145,6 @@ 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;
@@ -224,31 +193,21 @@ function AgentModeControlView({
return (
<>
<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>
<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>
<Combobox
options={options}
value={selectedMode.id}
@@ -302,7 +261,6 @@ export const AgentModeControl = memo(function AgentModeControl({
compareAvailableModes,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const { updatePreferences } = useFormPreferences();
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
@@ -314,18 +272,7 @@ export const AgentModeControl = memo(function AgentModeControl({
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client || !slice?.provider) return;
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: slice.provider,
updates: {
mode: modeId || undefined,
},
}),
).catch((error) => {
console.warn("[AgentModeControl] persist mode preference failed", error);
});
if (!client) return;
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
@@ -334,7 +281,7 @@ export const AgentModeControl = memo(function AgentModeControl({
toast.error(toErrorMessage(error));
});
},
[agentId, client, slice?.provider, toast, updatePreferences],
[agentId, client, toast],
);
if (!slice || availableModes.length === 0) return null;
@@ -413,13 +360,4 @@ 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,14 +1,5 @@
import { describe, expect, it } from "vitest";
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[];
import { resolveAgentControlsMode } from "./mode";
describe("resolveAgentControlsMode", () => {
it("uses ready mode when no controlled agent controls are provided", () => {
@@ -38,32 +29,3 @@ 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,20 +1,4 @@
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";
+83 -101
View File
@@ -89,7 +89,6 @@ 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";
@@ -210,10 +209,8 @@ function renderContextWindowMeter(
showPercentage: boolean,
serverId: string,
provider: string | null,
pending: boolean,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null;
}
return (
@@ -224,7 +221,6 @@ function renderContextWindowMeter(
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending}
/>
);
}
@@ -1646,9 +1642,6 @@ export function Composer({
agentState.contextWindowUsedTokens,
);
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeter = useMemo(
() =>
renderContextWindowMeter(
@@ -1658,7 +1651,6 @@ export function Composer({
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
),
[
contextWindowMaxTokens,
@@ -1667,7 +1659,6 @@ export function Composer({
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
@@ -1747,15 +1738,8 @@ export function Composer({
);
const leftContent = useMemo(
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
}),
[agentControls, agentId, focusInput, isCompactLayout, serverId],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
[agentId, focusInput, serverId, agentControls, isCompactLayout],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1868,92 +1852,90 @@ export function Composer({
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<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}
/>
</View>
{/* 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>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
);
}
@@ -1,31 +0,0 @@
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,39 +81,6 @@ 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,43 +46,6 @@ 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;
@@ -91,13 +54,32 @@ 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]: applyProviderPreferenceUpdates(existing, updates),
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
+20 -20
View File
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.30.0",
version: "0.29.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.30.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.29.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.29",
version: "3.0.27",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.29", "--acp"],
command: ["npx", "-y", "cline@3.0.27", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.109.3",
version: "2.108.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.3", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.108.2", "--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.9",
version: "0.2.7",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.9", "acp"],
command: ["npx", "-y", "dimcode@0.2.7", "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.7",
version: "0.4.1",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.7", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.1", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.157.1",
version: "0.151.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.157.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.151.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.21",
version: "0.7.20",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.21", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.20", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -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.19",
version: "1.1.18",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.19", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.18", "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.26",
version: "1.0.24",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.26", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.24", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.1",
version: "0.18.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.1", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
+25 -122
View File
@@ -122,7 +122,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
interface HighlightedTextProps {
tokens: HighlightToken[];
textMetricsStyle: TextStyle;
wrapLines?: boolean;
testID?: string;
}
@@ -141,37 +140,14 @@ 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,
textMetricsStyle,
wrapLines = false,
testID,
}: HighlightedTextProps) {
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
const containerStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
],
[textMetricsStyle, wrapLines],
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
[wrapLines],
);
const keyedTokens = useMemo(
@@ -270,7 +246,6 @@ function DiffGutterCell({
lineNumber,
type,
gutterWidth,
textMetricsStyle,
reviewTarget,
reviewActions,
isLineHovered,
@@ -281,7 +256,6 @@ function DiffGutterCell({
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
isLineHovered?: boolean;
@@ -289,27 +263,23 @@ 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, rowMetricsStyle, gutterWidth, style],
[type, gutterWidth, style],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
],
[textMetricsStyle, type],
[type],
);
const comments = useMemo(
() =>
@@ -327,7 +297,6 @@ function DiffGutterCell({
comments={comments}
isEditorOpen={isEditorOpen}
isLineHovered={isLineHovered}
lineHeight={lineHeight}
onStartComment={onStartComment}
style={containerStyle}
actionTestID={actionTestID}
@@ -342,7 +311,6 @@ function DiffGutterCell({
function DiffTextLine({
line,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
onHoverChange,
@@ -352,7 +320,6 @@ function DiffTextLine({
}: {
line: DiffLine;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
@@ -361,16 +328,14 @@ function DiffTextLine({
textTestID?: string;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type, rowMetricsStyle],
() => [styles.textLineContainer, lineTypeBackground(line.type)],
[line.type],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -378,7 +343,7 @@ function DiffTextLine({
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, textMetricsStyle, wrapLines],
[line.type, wrapLines],
);
return (
@@ -391,12 +356,7 @@ function DiffTextLine({
style={containerStyle}
>
{line.type !== "header" && visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
testID={textTestID}
/>
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
) : (
<Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)}
@@ -409,7 +369,6 @@ function DiffTextLine({
function SplitTextLine({
line,
wrapLines,
textMetricsStyle,
reviewActions,
onHoverChange,
hoverTargetKey,
@@ -417,23 +376,20 @@ 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), rowMetricsStyle],
[line?.type, rowMetricsStyle],
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
[line?.type],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -441,7 +397,7 @@ function SplitTextLine({
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, textMetricsStyle, wrapLines],
[line, wrapLines],
);
return (
@@ -454,11 +410,7 @@ function SplitTextLine({
style={containerStyle}
>
{visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -471,7 +423,6 @@ function DiffLineView({
lineNumber,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
}: {
@@ -479,22 +430,19 @@ 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), rowMetricsStyle],
[line.type, rowMetricsStyle],
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
[line.type],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -502,7 +450,7 @@ function DiffLineView({
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, textMetricsStyle, wrapLines],
[line.type, wrapLines],
);
return (
@@ -516,18 +464,13 @@ 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}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
@@ -539,27 +482,23 @@ 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), rowMetricsStyle],
[line?.type, rowMetricsStyle],
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
[line?.type],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -567,7 +506,7 @@ function SplitDiffLine({
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, textMetricsStyle, wrapLines],
[line, wrapLines],
);
return (
@@ -581,18 +520,13 @@ 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}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -715,7 +649,6 @@ function SplitDiffColumn({
side,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewActions,
showDivider = false,
}: {
@@ -723,7 +656,6 @@ function SplitDiffColumn({
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
showDivider?: boolean;
}) {
@@ -745,10 +677,6 @@ 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]);
@@ -760,7 +688,7 @@ function SplitDiffColumn({
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={headerLineTextStyle}>{row.content}</Text>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
</View>
);
}
@@ -776,7 +704,6 @@ function SplitDiffColumn({
line={line}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<InlineReviewRow
@@ -799,13 +726,7 @@ function SplitDiffColumn({
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<DiffGutterCell
key={key}
lineNumber={null}
type="header"
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
/>
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
);
}
const line = side === "left" ? row.left : row.right;
@@ -821,7 +742,6 @@ function SplitDiffColumn({
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={
@@ -849,7 +769,7 @@ function SplitDiffColumn({
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={headerLineTextStyle}>{row.content}</Text>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
</View>
);
}
@@ -865,7 +785,6 @@ function SplitDiffColumn({
<SplitTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey}
@@ -991,7 +910,6 @@ function DiffFileBody({
layout,
wrapLines,
codeFontSize,
textMetricsStyle,
reviewActions,
onBodyHeightChange,
testID,
@@ -1000,7 +918,6 @@ function DiffFileBody({
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
@@ -1061,7 +978,6 @@ function DiffFileBody({
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<SplitDiffColumn
@@ -1069,7 +985,6 @@ function DiffFileBody({
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
showDivider
/>
@@ -1090,7 +1005,6 @@ function DiffFileBody({
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
@@ -1117,7 +1031,6 @@ function DiffFileBody({
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
@@ -1146,7 +1059,6 @@ function DiffFileBody({
<DiffTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
@@ -1709,14 +1621,6 @@ 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(
@@ -2105,7 +2009,6 @@ 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`}
@@ -2114,7 +2017,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
},
[
codeFontSize,
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
@@ -2778,6 +2680,7 @@ 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];
@@ -1,87 +0,0 @@
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,6 +1,4 @@
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";
@@ -1,39 +0,0 @@
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",
},
}));
@@ -1,113 +0,0 @@
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,
},
}));
+19 -140
View File
@@ -25,13 +25,10 @@ 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,
@@ -45,12 +42,9 @@ 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, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PrActivitySkeleton } from "./activity-skeleton";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import {
collapseActivity,
expandActivity,
@@ -91,8 +85,6 @@ 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 });
@@ -146,13 +138,6 @@ 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
@@ -191,17 +176,13 @@ 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,
@@ -218,24 +199,6 @@ 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);
}, []);
@@ -432,47 +395,6 @@ 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 }) => (
<>
@@ -498,6 +420,8 @@ export function PullRequestPane({
)}
</Pressable>
<View style={styles.divider} />
<Section
title="Checks"
open={checksOpen}
@@ -564,30 +488,27 @@ export function PullRequestPane({
size="xs"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
Add all to chat
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
{visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
) : 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}
) : (
visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
)}
</Section>
</ScrollView>
</View>
@@ -1315,48 +1236,6 @@ 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,37 +435,6 @@ 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,7 +27,6 @@ export interface UsePrPaneDataResult {
data: PrPaneData | null;
prNumber: number | null;
isLoading: boolean;
activityLoading: boolean;
isRefreshing: boolean;
error: Error | null;
githubFeaturesEnabled: boolean;
@@ -168,14 +167,13 @@ 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 || timelinePending,
activityLoading: timelinePending,
isLoading:
input.statusIsLoading ||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
isRefreshing: statusRefreshing || timelineRefreshing,
error: firstNonSuppressedError({
statusPayloadError: input.statusPayloadError,
+57 -63
View File
@@ -7,7 +7,6 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
import {
buildGitActions,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
type GitActions,
} from "@/git/policy";
@@ -569,8 +568,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActionsInput = useMemo<BuildGitActionsInput>(
() => ({
const gitActions: GitActions = useMemo(() => {
const actions = buildGitActions({
isGit,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
@@ -685,66 +684,61 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorktree,
},
},
}),
[
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 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,
]);
return { gitActions, branchLabel, isGit };
}
@@ -38,7 +38,6 @@ import {
type ServiceUrlBehavior,
type Settings,
type SettingsDeps,
type WorkspaceTitleSource,
} from "./storage";
export {
@@ -68,7 +67,6 @@ export type {
ServiceUrlBehavior,
Settings,
SettingsDeps,
WorkspaceTitleSource,
};
const productionDeps: SettingsDeps = {
@@ -174,9 +172,6 @@ export function useSettings(): UseSettingsReturn {
if (updates.syntaxTheme !== undefined) {
appUpdates.syntaxTheme = updates.syntaxTheme;
}
if (updates.workspaceTitleSource !== undefined) {
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -61,14 +61,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.language).toBe("system");
});
it("defaults workspace title source to title when storage is empty", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("title");
});
it("loads configured terminal scrollback lines from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -81,30 +73,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.terminalScrollbackLines).toBe(42_000);
});
it("loads configured workspace title source from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "branch" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("branch");
});
it("drops an unknown workspace title source back to title", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "directory" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("title");
});
it("normalizes terminal scrollback lines from storage", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -11,11 +11,9 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
export type WorkspaceTitleSource = "title" | "branch";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
@@ -38,7 +36,6 @@ export interface AppSettings {
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
}
export interface Settings extends AppSettings {
@@ -57,7 +54,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -198,12 +194,6 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
return result;
}
@@ -1,102 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarShortcutModel } from "./use-sidebar-shortcut-model";
function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEntry {
return {
workspaceKey: `srv:${workspaceId}`,
serverId: "srv",
workspaceId,
projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
projectKind: "git",
workspaceKind: "worktree",
name: workspaceId,
title: null,
currentBranch: null,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};
}
function project(projectKey: string): SidebarProjectEntry {
return {
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
canCreateWorktree: true,
workspaces: [workspace(projectKey, `${projectKey}-main`)],
};
}
const PROJECTS_BOTH = [project("p1"), project("p2")];
const PROJECTS_ONLY_SECOND = [project("p2")];
function Probe({ projectSet }: { projectSet: "both" | "onlySecond" }) {
const projects = projectSet === "both" ? PROJECTS_BOTH : PROJECTS_ONLY_SECOND;
useSidebarShortcutModel({ projects });
return null;
}
describe("useSidebarShortcutModel", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
collapsedStatusGroupKeys: new Set(),
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("keeps a collapsed project collapsed when the project list temporarily omits it", async () => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(["p1"]),
});
await act(async () => {
root?.render(<Probe projectSet="both" />);
});
await act(async () => {
root?.render(<Probe projectSet="onlySecond" />);
});
await act(async () => {
root?.render(<Probe projectSet="both" />);
});
expect(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys.has("p1")).toBe(true);
});
});
@@ -1,10 +1,13 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[] }) {
const { projects } = input;
export function useSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
}) {
const { projects, isInitialLoad } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -24,6 +27,19 @@ export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[]
[collapsedProjectKeys, projects],
);
useEffect(() => {
if (isInitialLoad || projects.length === 0) {
return;
}
const projectKeys = new Set(projects.map((project) => project.projectKey));
for (const key of collapsedProjectKeys) {
if (!projectKeys.has(key)) {
setProjectCollapsed(key, false);
}
}
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
return {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
-3
View File
@@ -183,7 +183,6 @@ 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");
@@ -244,7 +243,6 @@ 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");
});
@@ -290,7 +288,6 @@ 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.",
);
+1 -5
View File
@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
hints: {
thinking: "وضع التفكير",
model: "تغيير النموذج",
mode: "تغيير الوضع",
mode: "تغيير وضع الإذن",
},
},
agentStream: {
@@ -717,9 +717,6 @@ export const ar: TranslationResources = {
failedOpen: "فشل في فتح مساحة العمل",
},
pr: {
actions: {
viewPullRequest: "عرض",
},
sections: {
checks: "الشيكات",
reviews: "التعليقات",
@@ -1567,7 +1564,6 @@ export const ar: TranslationResources = {
toggleFocusMode: "تبديل وضع التركيز",
cycleTheme: "موضوع الدورة",
focusMessageInput: "التركيز على إدخال الرسالة",
cycleAgentMode: "تبديل وضع الوكيل",
toggleVoiceMode: "تبديل الوضع الصوتي",
startStopDictation: "بدء إملاء /stop",
interruptAgent: "عامل المقاطعة",
+1 -5
View File
@@ -163,7 +163,7 @@ export const en = {
hints: {
thinking: "Thinking mode",
model: "Change model",
mode: "Change mode",
mode: "Change permission mode",
},
},
agentStream: {
@@ -724,9 +724,6 @@ export const en = {
failedOpen: "Failed to open workspace",
},
pr: {
actions: {
viewPullRequest: "View",
},
sections: {
checks: "Checks",
reviews: "Reviews",
@@ -1573,7 +1570,6 @@ 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",
+1 -5
View File
@@ -165,7 +165,7 @@ export const es: TranslationResources = {
hints: {
thinking: "Modo de pensamiento",
model: "Cambiar modelo",
mode: "Cambiar modo",
mode: "Cambiar modo de permiso",
},
},
agentStream: {
@@ -744,9 +744,6 @@ export const es: TranslationResources = {
failedOpen: "No se pudo abrir el espacio de trabajo",
},
pr: {
actions: {
viewPullRequest: "Ver",
},
sections: {
checks: "cheques",
reviews: "Reseñas",
@@ -1605,7 +1602,6 @@ 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",
+1 -5
View File
@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
hints: {
thinking: "Mode réflexion",
model: "Changer de modèle",
mode: "Changer de mode",
mode: "Changer le mode d'autorisation",
},
},
agentStream: {
@@ -743,9 +743,6 @@ export const fr: TranslationResources = {
failedOpen: "Échec de l'ouverture de l'espace de travail",
},
pr: {
actions: {
viewPullRequest: "Voir",
},
sections: {
checks: "Chèques",
reviews: "Avis",
@@ -1609,7 +1606,6 @@ 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",
+1 -5
View File
@@ -165,7 +165,7 @@ export const ru: TranslationResources = {
hints: {
thinking: "Режим мышления",
model: "Изменить модель",
mode: "Изменить режим",
mode: "Изменить режим разрешений",
},
},
agentStream: {
@@ -736,9 +736,6 @@ export const ru: TranslationResources = {
failedOpen: "Не удалось открыть рабочую область",
},
pr: {
actions: {
viewPullRequest: "Открыть",
},
sections: {
checks: "Чеки",
reviews: "Отзывы",
@@ -1597,7 +1594,6 @@ export const ru: TranslationResources = {
toggleFocusMode: "Переключить режим фокусировки",
cycleTheme: "Циклическая тема",
focusMessageInput: "Фокус ввода сообщения",
cycleAgentMode: "Переключить режим агента",
toggleVoiceMode: "Переключить голосовой режим",
startStopDictation: "Начать диктовку /stop",
interruptAgent: "Агент прерываний",
+1 -5
View File
@@ -165,7 +165,7 @@ export const zhCN: TranslationResources = {
hints: {
thinking: "Thinking mode",
model: "切换 Model",
mode: "更改模式",
mode: "切换权限 Mode",
},
},
agentStream: {
@@ -711,9 +711,6 @@ export const zhCN: TranslationResources = {
failedOpen: "打开 workspace 失败",
},
pr: {
actions: {
viewPullRequest: "查看",
},
sections: {
checks: "Checks",
reviews: "Reviews",
@@ -1548,7 +1545,6 @@ export const zhCN: TranslationResources = {
toggleFocusMode: "切换专注模式",
cycleTheme: "循环切换主题",
focusMessageInput: "聚焦消息输入框",
cycleAgentMode: "循环切换代理模式",
toggleVoiceMode: "切换语音模式",
startStopDictation: "开始/停止听写",
interruptAgent: "中断 Agent",
+1 -2
View File
@@ -12,8 +12,7 @@ export type MessageInputKeyboardActionKind =
| "dictation-cancel"
| "dictation-confirm"
| "voice-toggle"
| "voice-mute-toggle"
| "mode-cycle";
| "voice-mute-toggle";
export type KeyboardActionId =
| "agent.interrupt"
@@ -9,7 +9,6 @@ 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"
@@ -40,7 +39,6 @@ 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,13 +299,6 @@ 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" },
@@ -434,16 +427,6 @@ 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 },
@@ -587,7 +570,6 @@ 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"],
},
},
{
@@ -635,14 +617,12 @@ 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,7 +152,6 @@ 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",
@@ -911,20 +910,6 @@ 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,7 +276,6 @@ 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,7 +82,6 @@ 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">(
@@ -1,21 +0,0 @@
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,46 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine";
import {
clearHistorySyncErrorAfterSuccessfulSync,
reconcileMissingAgentStateWithPresentAgent,
} from "./agent-panel-load-state";
describe("reconcileMissingAgentStateWithPresentAgent", () => {
it("clears lookup-only states once the agent record is present", () => {
expect(reconcileMissingAgentStateWithPresentAgent({ kind: "resolving" })).toEqual({
kind: "idle",
});
expect(
reconcileMissingAgentStateWithPresentAgent({
kind: "not_found",
message: "Agent not found: agent-1",
}),
).toEqual({ kind: "idle" });
});
it("preserves history sync errors while the agent record is present", () => {
const state: AgentScreenMissingState = {
kind: "error",
message: "Failed to get logs: session is archived",
};
expect(reconcileMissingAgentStateWithPresentAgent(state)).toBe(state);
});
});
describe("clearHistorySyncErrorAfterSuccessfulSync", () => {
it("clears a sync error after a later successful refresh", () => {
expect(
clearHistorySyncErrorAfterSuccessfulSync({
kind: "error",
message: "Failed to get logs: session is archived",
}),
).toEqual({ kind: "idle" });
});
it("leaves non-error states alone", () => {
const state: AgentScreenMissingState = { kind: "resolving" };
expect(clearHistorySyncErrorAfterSuccessfulSync(state)).toBe(state);
});
});
@@ -1,19 +0,0 @@
import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine";
export function reconcileMissingAgentStateWithPresentAgent(
state: AgentScreenMissingState,
): AgentScreenMissingState {
if (state.kind === "resolving" || state.kind === "not_found") {
return { kind: "idle" };
}
return state;
}
export function clearHistorySyncErrorAfterSuccessfulSync(
state: AgentScreenMissingState,
): AgentScreenMissingState {
if (state.kind === "error") {
return { kind: "idle" };
}
return state;
}
+5 -15
View File
@@ -53,10 +53,6 @@ import {
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
clearHistorySyncErrorAfterSuccessfulSync,
reconcileMissingAgentStateWithPresentAgent,
} from "@/panels/agent-panel-load-state";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
@@ -851,15 +847,9 @@ function ChatAgentContent({
if (!agentId) {
return;
}
ensureAgentIsInitialized(agentId)
.then(() => {
setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync);
return undefined;
})
.catch((error) => {
handleHistorySyncFailure({ origin, error });
return undefined;
});
ensureAgentIsInitialized(agentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[agentId, ensureAgentIsInitialized, handleHistorySyncFailure],
);
@@ -1021,8 +1011,8 @@ function ChatAgentContent({
return;
}
if (agentState.id) {
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
setMissingAgentState(reconcileMissingAgentStateWithPresentAgent);
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
return;
}
@@ -511,59 +511,6 @@ 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,24 +144,6 @@ 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;
@@ -277,11 +259,18 @@ function resolveModeId(input: {
input;
if (userModified) return currentModeId;
if (!provider) return "";
return resolvePreferredModeId({
initialModeId: initialValues?.modeId,
preferredModeId: providerPrefs?.mode,
providerDef,
});
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] ?? "";
}
function resolveModelField(input: {
@@ -422,10 +411,11 @@ function pickNextModeForProvider(input: {
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerDef, providerPrefs } = input;
return resolvePreferredModeId({
preferredModeId: providerPrefs?.mode,
providerDef,
});
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? "";
}
function pickNextModeForProviderAndModel(input: {
@@ -435,8 +425,14 @@ function pickNextModeForProviderAndModel(input: {
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const currentModeId = normalizeSelectedModeId(input.currentModeId);
if (input.currentProvider === input.provider && currentModeId) return currentModeId;
const validModeIds = input.providerDef?.modes.map((m) => m.id) ?? [];
if (
input.currentProvider === input.provider &&
input.currentModeId &&
validModeIds.includes(input.currentModeId)
) {
return input.currentModeId;
}
return pickNextModeForProvider({
providerDef: input.providerDef,
providerPrefs: input.providerPrefs,
+9 -6
View File
@@ -22,7 +22,7 @@ function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null;
@@ -65,7 +65,11 @@ export function ProviderUsageCard({
return (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
<ThemedProviderUsageIcon
iconKey={usage.providerId}
size={16}
uniProps={foregroundIconColor}
/>
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
</Text>
@@ -125,12 +129,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
},
containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
gap: theme.spacing[2],
},
header: {
flexDirection: "row",
@@ -140,7 +143,7 @@ const styles = StyleSheet.create((theme) => ({
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
@@ -167,7 +170,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
bars: {
gap: theme.spacing[3],
gap: theme.spacing[2],
},
details: {
gap: theme.spacing[1],
@@ -54,13 +54,9 @@ export function ProviderUsageTooltipSection({
const styles = StyleSheet.create((theme) => ({
divider: {
height: 1,
// Same token the popover draws its own outline with, so the rule reads as the
// popover's edge. `border` is invisible here (equals the popover background).
backgroundColor: theme.colors.borderAccent,
marginVertical: theme.spacing[2],
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
marginHorizontal: -theme.spacing[2],
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1] + 1,
},
detail: {
color: theme.colors.foregroundMuted,
+4 -24
View File
@@ -282,7 +282,6 @@ export function InlineReviewGutterCell({
reviewTarget,
comments,
isLineHovered = false,
lineHeight,
onStartComment,
style,
actionTestID,
@@ -292,7 +291,6 @@ export function InlineReviewGutterCell({
comments: readonly ReviewDraftComment[];
isEditorOpen: boolean;
isLineHovered?: boolean;
lineHeight?: number;
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
actionTestID?: string;
@@ -336,28 +334,10 @@ 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, 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],
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
[hasComments],
);
return (
@@ -373,11 +353,11 @@ export function InlineReviewGutterCell({
onPressOut={handlePressOut}
style={pressableStyle}
>
<View style={innerStyle}>
<View style={styles.gutterInner}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<View style={styles.gutterActionIcon} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}
@@ -1,57 +0,0 @@
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 };
}
@@ -1,31 +0,0 @@
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 { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
@@ -34,6 +34,7 @@ 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";
@@ -65,7 +66,9 @@ 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 {
@@ -102,6 +105,8 @@ 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";
@@ -173,6 +178,7 @@ import {
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
} from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import {
useWorkspaceTerminals,
type TerminalProfileInput,
@@ -190,7 +196,6 @@ 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";
@@ -198,6 +203,7 @@ 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,
@@ -862,6 +868,29 @@ 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(() => {
@@ -894,12 +923,16 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
isRouteFocused,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
return (
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
/>
<ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={effectiveRouteFocused}
/>
</ExplorerSidebarAnimationProvider>
);
});
@@ -1561,7 +1594,7 @@ function shouldShowWorkspaceExplorerSidebar(input: {
isFocusModeEnabled: boolean;
isMobile: boolean;
}): boolean {
return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
@@ -1643,6 +1676,56 @@ 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,
@@ -1807,6 +1890,7 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
);
@@ -1823,6 +1907,16 @@ 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;
@@ -1870,7 +1964,7 @@ function WorkspaceScreenContent({
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
);
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
@@ -2082,22 +2176,54 @@ function WorkspaceScreenContent({
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
const requestedWorkspaceSetupStatusKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
if (!isRouteFocused) {
return;
}
ensureWorkspaceSetupStatus({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
client,
});
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;
};
}, [
client,
ensureWorkspaceSetupStatus,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
upsertWorkspaceSetupProgress,
workspaceSetupSnapshot,
]);
useEffect(() => {
@@ -2193,6 +2319,9 @@ function WorkspaceScreenContent({
const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) {
if (isMobile) {
showMobileAgent();
}
if (!persistenceKey) {
return;
}
@@ -2205,7 +2334,7 @@ function WorkspaceScreenContent({
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
);
const handleOpenFileFromChat = useCallback(
@@ -3598,7 +3727,12 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
{isMobile ? (
<View style={styles.content}>{content}</View>
<MobileExplorerOpenGestureSurface
enabled={Boolean(activeExplorerCheckout)}
onOpenExplorer={openExplorerForWorkspace}
>
{content}
</MobileExplorerOpenGestureSurface>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
@@ -16,11 +16,7 @@ import {
workspaceEqualityFns,
type SidebarOrderSnapshot,
} from "./selectors";
import {
useSessionStore,
type EmptyProjectDescriptor,
type WorkspaceDescriptor,
} from "../session-store";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
const SERVER_ID = "test-server";
@@ -91,12 +87,6 @@ function emptySidebarOrder(): SidebarOrderSnapshot {
};
}
function selectWorkspaceStructureProjectKeys(
state: Parameters<typeof selectWorkspaceStructureProjects>[0],
): string[] {
return selectWorkspaceStructureProjects(state, SERVER_ID).map((project) => project.projectKey);
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
@@ -218,38 +208,6 @@ describe("workspace structure composition", () => {
});
}
it("keeps a project parent visible throughout the last workspace archive transition", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
projectRootPath: "/repo/a",
workspaceDirectory: "/repo/a",
});
const emptyProject: EmptyProjectDescriptor = {
projectId: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectRootPath: "/repo/a",
projectKind: "git",
};
initializeWorkspaces([workspace]);
const emittedProjectKeys = [selectWorkspaceStructureProjectKeys(useSessionStore.getState())];
const stop = useSessionStore.subscribe((state) => {
emittedProjectKeys.push(selectWorkspaceStructureProjectKeys(state));
});
try {
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
} finally {
stop();
}
expect(emittedProjectKeys).toEqual([["project-a"], ["project-a"]]);
});
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });

Some files were not shown because too many files have changed in this diff Show More