Compare commits

..

8 Commits

Author SHA1 Message Date
Mohamed Boudra
9376de1ef1 Restore terminal toolbar focus handling 2026-06-19 11:54:12 +07:00
Mohamed Boudra
ad30f65549 Use tracked terminal mode for native paste 2026-06-19 11:41:41 +07:00
Mohamed Boudra
ee92843965 Stabilize terminal resize browser test 2026-06-19 11:39:03 +07:00
Mohamed Boudra
e39cbb4643 Stop per-key terminal resize claims 2026-06-19 11:24:38 +07:00
Mohamed Boudra
25349e0fe5 Replay bracketed paste in terminal preamble 2026-06-19 11:08:49 +07:00
Mohamed Boudra
e5322b5b63 Remove dead native terminal input branch 2026-06-19 10:57:14 +07:00
Mohamed Boudra
2b4f133a6c Address mobile terminal review feedback 2026-06-19 10:43:21 +07:00
Mohamed Boudra
ff9991acc2 Improve mobile terminal typing and selection 2026-06-19 10:20:34 +07:00
354 changed files with 17055 additions and 25506 deletions

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

View File

@@ -14,23 +14,14 @@ 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.
- **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.
`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.
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
@@ -79,8 +70,6 @@ parentAgentId === thisAgent.id AND !archivedAt
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
@@ -88,7 +77,6 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Detach button on track rows** — lets a subagent continue independently without killing its work
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
@@ -113,11 +101,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.

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) |

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`.

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
@@ -66,23 +54,6 @@ Boundary tests should assert observable behavior: cold reads may call provider a
---
## Provider Usage Fetchers
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
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
### 1. Create the provider class
@@ -342,13 +313,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>;
@@ -371,7 +343,7 @@ interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void | AgentProviderNotice>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
@@ -383,7 +355,7 @@ interface AgentSession {
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
@@ -391,8 +363,6 @@ interface AgentSession {
}
```
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces

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).

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

View File

@@ -1 +1 @@
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
sha256-lwIf9Z0uwDdNyAFu+L03pVwlQSuasYWIpn1Fsx3zxJw=

42
package-lock.json generated
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": [
@@ -35243,7 +35243,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 +35566,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 +35817,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 +35831,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 +36074,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 +36970,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 +37201,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.100",
"version": "0.1.97",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37213,7 +37213,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 +37431,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 +37848,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",

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": [

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();
}
});

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)}`,
});

View File

@@ -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();
}
});

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> {
@@ -369,13 +362,11 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
}
return { id: createdWorkspace.workspace.id };
return { id: opened.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -407,22 +398,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> {

View File

@@ -1,9 +1,7 @@
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
@@ -47,65 +45,11 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
await removeItem.click();
}
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: path.basename(projectPath) })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
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", () => {
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
const repo = await createTempGitRepo("empty-project-add-");
const client = await connectSeedClient();
let projectId: string | null = null;
try {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(path.basename(repo.path));
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
// Model B makes the project a first-class parent: archiving its last workspace
// must not delete the project. The per-project "+ New workspace" row is gone;
// the empty project keeps its parent row, and creation stays reachable from the
// project row's own new-worktree icon (git projects) and the global button.
test.describe("Empty project persists", () => {
test("archiving the only workspace keeps the project row with creation still reachable", async ({
page,
}) => {
@@ -113,8 +57,8 @@ test.describe("Project with no workspaces persists", () => {
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
const projectNewWorktreeIcon = page.getByTestId(
`sidebar-project-new-worktree-${workspace.projectId}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -127,21 +71,24 @@ test.describe("Project with no workspaces persists", () => {
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
// The workspace row goes away, but its project parent stays as an empty
// project row. Creation is still reachable: the project row keeps its own
// new-worktree icon (revealed on hover) and the global button persists.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
// The project survives a reload after its last workspace is archived.
// The empty project survives a reload — it is persisted, not a transient
// artifact of the just-archived workspace still lingering in memory.
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
@@ -170,21 +117,18 @@ 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);
expect(readded.error).toBeNull();
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
const reopened = await workspace.client.openProject(workspace.repoPath);
expect(reopened.error).toBeNull();
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}

View File

@@ -35,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
openProject(cwd: string): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -52,14 +56,18 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: opened.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -74,7 +82,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: opened.workspace.id,
};
}

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. */
@@ -161,35 +160,22 @@ export async function startRunningMockAgent(
const repo = await createTempGitRepo(opts.prefix);
const client = await connectSeedClient();
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: workspace.id,
workspaceId: opened.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.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 {
@@ -202,20 +188,10 @@ export async function openGithubWorkspace(
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repoPath },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, workspace.id);
await selectWorkspaceInSidebar(page, opened.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) };
}

View File

@@ -13,17 +13,16 @@ type NewWorkspaceDaemonClient = Pick<
| "close"
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "openProject"
| "patchDaemonConfig"
| "removeProject"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
@@ -38,7 +37,7 @@ function requireWorkspace(payload: WorkspacePayload) {
throw new Error(payload.error);
}
if (!payload.workspace) {
throw new Error("workspace.create returned no workspace.");
throw new Error("openProject returned no workspace.");
}
return payload.workspace;
}
@@ -97,11 +96,7 @@ export async function openProjectViaDaemon(
client: NewWorkspaceDaemonClient,
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(
await client.createWorkspace({
source: { kind: "directory", path: repoPath },
}),
);
const workspace = requireWorkspace(await client.openProject(repoPath));
return openedProjectFromWorkspace(workspace);
}

View File

@@ -1,154 +0,0 @@
import type { Page } from "@playwright/test";
import type { ProviderUsage } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
interface ProviderUsageFixturePayload {
fetchedAt: string;
providers: ProviderUsage[];
}
export interface ProviderUsageFixture {
requestCount(): number;
waitForRequestCount(count: number): Promise<void>;
}
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function withProviderUsageFeature(message: WebSocketMessage): string | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as {
type?: unknown;
message?: {
type?: unknown;
payload?: Record<string, unknown>;
};
};
const payload = maybeEnvelope.message?.payload;
if (
maybeEnvelope.type !== "session" ||
maybeEnvelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
return JSON.stringify({
...maybeEnvelope,
message: {
...maybeEnvelope.message,
payload: {
...payload,
features: {
...(typeof payload.features === "object" && payload.features !== null
? payload.features
: {}),
providerUsageList: true,
},
},
},
});
}
export async function installProviderUsageFixture(
page: Page,
payloads: ProviderUsageFixturePayload[],
): Promise<ProviderUsageFixture> {
let requests = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
function notifyWaiters() {
for (const waiter of waiters.splice(0)) {
if (requests >= waiter.count) {
waiter.resolve();
} else {
waiters.push(waiter);
}
}
}
function payloadForRequest(): ProviderUsageFixturePayload {
const index = Math.min(requests - 1, payloads.length - 1);
const payload = payloads[index];
if (!payload) {
throw new Error("Provider usage fixture requires at least one payload.");
}
return payload;
}
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "provider.usage.list.request") {
requests += 1;
const requestId = sessionMessage.requestId;
if (typeof requestId !== "string") {
throw new Error("provider.usage.list.request missing requestId");
}
const payload = payloadForRequest();
notifyWaiters();
ws.send(
JSON.stringify({
type: "session",
message: {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: payload.fetchedAt,
providers: payload.providers,
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
ws.send(serverInfo ?? message);
});
});
return {
requestCount() {
return requests;
},
waitForRequestCount(count: number) {
if (requests >= count) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
waiters.push({ count, resolve });
});
},
};
}

View File

@@ -14,7 +14,6 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
projectId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -172,33 +171,29 @@ export async function launchAgent(input: {
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectSeedClient();
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 opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
workspaceId: createdWorkspace.workspace.id,
workspaceId: opened.workspace.id,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
projectId: createdWorkspace.workspace.projectId,
workspaceId: createdWorkspace.workspace.id,
workspaceId: opened.workspace.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { workspaceId: createdWorkspace.workspace.id, agentId: agent.id });
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.removeProject(handle.projectId).catch(() => undefined);
await handle.client.close().catch(() => undefined);
}

View File

@@ -4,15 +4,6 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import { connectDaemonClient } from "./daemon-client-loader";
import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -22,18 +13,17 @@ export interface SeedWorkspaceDescriptor {
export interface SeedDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
project: {
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -49,7 +39,7 @@ export interface SeedDaemonClient {
};
title?: string;
}): Promise<{
workspace: SeedWorkspaceDescriptor | null;
workspace: { id: string; name: string } | null;
error: string | null;
}>;
/**
@@ -92,7 +82,6 @@ export interface SeedDaemonClient {
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
@@ -136,7 +125,7 @@ export interface SeedDaemonClient {
agentId: string,
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
getLastServerInfoMessage(): {
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
features?: { worktreeRestore?: boolean } | null;
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
@@ -194,23 +183,19 @@ export async function seedWorkspace(options: {
: await createTempGitRepo(options.repoPrefix, options.repo);
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
const opened = await client.openProject(project.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${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: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},

View File

@@ -25,7 +25,7 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
@@ -374,7 +374,6 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.

View File

@@ -1,83 +0,0 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
export interface SeededSubagentPair {
parent: {
id: string;
title: string;
};
child: {
id: string;
title: string;
};
workspaceId: string;
}
export async function seedParentWithSubagent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentTitle: string; childTitle: string },
): Promise<SeededSubagentPair> {
const parent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.parentTitle,
modeId: "load-test",
model: "ten-second-stream",
});
const child = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.childTitle,
modeId: "load-test",
model: "ten-second-stream",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
},
});
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
child: {
id: child.id,
title: input.childTitle,
},
workspaceId: workspace.workspaceId,
};
}
export async function openSubagentsTrack(page: Page): Promise<void> {
await page.getByTestId("subagents-track-header").click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
});
}
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("Detach subagent?");
void dialog.accept();
});
const detachButton = page.getByTestId(`subagents-track-detach-${childId}`);
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}

View File

@@ -28,27 +28,22 @@ 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;
}
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectSeedClient();
const seedResult = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});
await tempRepo.cleanup().catch(() => {});
@@ -57,13 +52,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(() => {});
}

View File

@@ -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) {
@@ -61,21 +60,14 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
);
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
// Register the parent project so the sidebar lists it before we navigate.
const added = await client.addProject(repo.path);
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
projectIds.add(added.project.projectId);
await client.openProject(repo.path);
}
const created = await client.createWorkspace({
source: { kind: "directory", path: workspacePath },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
const opened = await client.openProject(workspacePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
}
const workspaceId = created.workspace.id;
projectIds.add(created.workspace.projectId);
const workspaceId = opened.workspace.id;
return {
workspaceId,
@@ -91,11 +83,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`, {

View File

@@ -11,15 +11,13 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
type WorkspaceSetupDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "addProject"
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgent"
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "removeProject"
| "openProject"
| "subscribeRawMessages"
>;
@@ -38,11 +36,9 @@ export async function openProjectViaDaemon(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.createWorkspace({
source: { kind: "directory", path: repoPath },
});
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace ${repoPath}`);
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
return {
id: result.workspace.id,
@@ -55,10 +51,7 @@ export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.addProject(repoPath);
if (!result.project || result.error) {
throw new Error(result.error ?? `Failed to add project ${repoPath}`);
}
await openProjectViaDaemon(client, repoPath);
}
export function projectNameFromPath(repoPath: string): string {

View File

@@ -8,8 +8,8 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
}
/**
* Wait for the sidebar to show at least one project row. Use this after a spec
* seeds a workspace/project; zero-project flows need their own assertion.
* Wait for the sidebar to show at least one project row, indicating that the
* WebSocket connection is up and workspace hydration has completed.
*/
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
await page
@@ -18,10 +18,6 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
.waitFor({ state: "visible", timeout });
}
export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise<void> {
await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout });
}
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
}

View File

@@ -58,11 +58,9 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
const client = await connectSeedClient();
try {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: PASEO_REPO_PATH },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
const opened = await client.openProject(PASEO_REPO_PATH);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
}
return {
prompt,
@@ -71,11 +69,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspace: {
client,
repoPath: PASEO_REPO_PATH,
workspaceId: createdWorkspace.workspace.id,
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
},

View File

@@ -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();
}
});
});

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 });
}

View File

@@ -1,152 +0,0 @@
import { expect, test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { installProviderUsageFixture } from "./helpers/provider-usage";
import { getServerId } from "./helpers/server-id";
import { openSettingsHostSection } from "./helpers/settings";
test.describe("provider usage settings", () => {
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
sourceLabel: "OpenUsage 0.6.27",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
expect(usageFixture.requestCount()).toBe(0);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
});
test("refresh invalidates and refetches usage", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
});
test("one provider error does not collapse the usage list", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Error", { exact: true })).toBeVisible();
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("71%")).toBeVisible();
});
});

View File

@@ -1,113 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installProviderUsageFixture } from "./helpers/provider-usage";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgent(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-usage-tooltip-",
title: "Provider usage tooltip e2e",
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
return session;
}
test.describe("provider usage tooltip", () => {
test("fetches usage when the context tooltip opens and renders the active provider", async ({
page,
}) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [
{
id: "session",
label: "Session",
usedPct: 42,
remainingPct: 58,
resetsAt: "2026-06-19T05:00:00.000Z",
},
],
},
],
},
]);
const session = await openMockAgent(page);
try {
expect(usageFixture.requestCount()).toBe(0);
await page.getByTestId("context-window-meter").hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Test plan")).toBeVisible();
await expect(page.getByText("Session", { exact: true })).toBeVisible();
await expect(page.getByText("42%")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 41 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 64 }],
},
],
},
]);
const session = await openMockAgent(page);
try {
const meter = page.getByTestId("context-window-meter");
await meter.hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
await page.mouse.move(0, 0);
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
await meter.hover();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
} finally {
await session.cleanup();
}
});
});

View File

@@ -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()}`,
});

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();

View File

@@ -1,44 +0,0 @@
import { test } from "./fixtures";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { expectAgentTabActive } from "./helpers/launcher";
import { openAgentRoute } from "./helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import {
detachSubagentFromTrack,
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
} from "./helpers/subagents";
test.describe("Subagent detach", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "subagent-detach-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("detaching a subagent focuses it as a workspace tab", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Detach parent",
childTitle: "Detached child",
});
await openAgentRoute(page, {
workspaceId: agents.workspaceId,
agentId: agents.parent.id,
});
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await detachSubagentFromTrack(page, agents.child.id);
await expectSubagentRowGone(page, agents.child.id);
await expectWorkspaceTabVisible(page, agents.child.id);
await expectAgentTabActive(page, agents.child.id);
});
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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()}`,
});

View File

@@ -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()}`,
});

View File

@@ -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);

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);

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Common helpers for native-terminal Maestro flows. Each per-flow harness
# sources this file and calls run_flow_with_setup.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
MAESTRO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
SIMULATOR_UDID="${PASEO_MAESTRO_UDID:-47FB40E1-3304-4516-B8BC-D75853EF1B47}"
log() {
echo "[native-terminal-harness] $*" >&2
}
terminal_ids() {
cd "$REPO_ROOT"
npm run --silent cli -- terminal ls --all --json 2>/dev/null \
| node -e 'const data=[]; process.stdin.on("data", c=>data.push(c)); process.stdin.on("end", ()=>{ const list=JSON.parse(data.join("")); for (const terminal of list) console.log(terminal.id); });'
}
send_to_terminal() {
local terminal_id="$1"
shift
cd "$REPO_ROOT"
npm run --silent cli -- terminal send-keys "$terminal_id" "$@"
}
set_simulator_clipboard() {
local text="$1"
if xcrun simctl pbcopy "$SIMULATOR_UDID" <<< "$text" 2>/dev/null; then
log "Set simulator clipboard"
else
log "WARNING: could not set simulator clipboard; flow may fail"
fi
}
read_simulator_clipboard() {
xcrun simctl pbpaste "$SIMULATOR_UDID" 2>/dev/null || true
}
capture_terminal() {
local terminal_id="$1"
cd "$REPO_ROOT"
npm run --silent cli -- terminal capture --scrollback "$terminal_id"
}
write_temp_flow() {
local name="$1"
local flow_dir="${TMPDIR:-/tmp}/paseo-native-terminal-maestro-flows"
mkdir -p "$flow_dir"
local flow_file
flow_file="$(mktemp "$flow_dir/$name.XXXXXX")"
cat >"$flow_file"
printf '%s\n' "$flow_file"
}
maestro_bin() {
local bin
bin="$(command -v maestro || true)"
if [[ -z "$bin" ]]; then
log "ERROR: maestro not found on PATH"
return 1
fi
printf '%s\n' "$bin"
}
run_maestro_flow() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro flow $(basename "$flow_file") with screenshots in $output_dir"
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
}
run_maestro_flow_allow_failure() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro probe $(basename "$flow_file") with screenshots in $output_dir"
set +e
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
local status=$?
set -e
return "$status"
}
assert_terminal_surface_visible() {
local flow_file
flow_file="$(write_temp_flow terminal-visible <<YAML
appId: $APP_ID
---
- assertVisible:
id: "terminal-virtual-keyboard"
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
ensure_terminal_surface_visible() {
if assert_terminal_surface_visible; then
return
fi
log "Terminal surface is not visible; trying to create/open a terminal from the workspace header"
local flow_file
flow_file="$(write_temp_flow ensure-terminal <<YAML
appId: $APP_ID
---
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- tapOn:
id: "workspace-header-menu-trigger"
- tapOn:
id: "workspace-header-new-terminal"
- extendedWaitUntil:
visible:
id: "terminal-virtual-keyboard"
timeout: 30000
YAML
)"
run_maestro_flow "$flow_file"
}
assert_text_visible() {
local text="$1"
local flow_file
flow_file="$(write_temp_flow text-visible <<YAML
appId: $APP_ID
---
- extendedWaitUntil:
visible: "$text"
timeout: 8000
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
visible_terminal_id() {
ensure_terminal_surface_visible
local ids
ids="$(terminal_ids)"
if [[ -z "$ids" ]]; then
log "ERROR: no terminals found via 'paseo terminal ls --all'"
return 1
fi
local terminal_id
while IFS= read -r terminal_id; do
[[ -n "$terminal_id" ]] || continue
local marker="VT_$RANDOM"
log "Probing visible terminal candidate $terminal_id"
send_to_terminal "$terminal_id" "clear" Enter >/dev/null
send_to_terminal "$terminal_id" "echo $marker" Enter >/dev/null
if assert_text_visible "$marker"; then
printf '%s\n' "$terminal_id"
return
fi
done <<< "$ids"
log "ERROR: no listed terminal matched the visible terminal surface"
return 1
}
assert_terminal_output_contains() {
local terminal_id="$1"
local marker="$2"
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "PASS: terminal output contains $marker"
return
fi
log "FAIL: terminal output did not contain $marker"
return 1
}
assert_terminal_output_count_at_least() {
local terminal_id="$1"
local marker="$2"
local minimum="$3"
local output
output="$(capture_terminal "$terminal_id")"
local count
count="$(MARKER="$marker" OUTPUT="$output" node -e 'const marker = process.env.MARKER ?? ""; const output = process.env.OUTPUT ?? ""; process.stdout.write(String(marker ? output.split(marker).length - 1 : 0));')"
if (( count >= minimum )); then
log "PASS: terminal output contains $marker $count times"
return
fi
log "FAIL: terminal output contained $marker $count times, expected at least $minimum"
return 1
}
assert_maestro_input_does_not_reach_terminal() {
local terminal_id="$1"
local marker="$2"
local flow_file
flow_file="$(write_temp_flow no-focus-input-probe <<YAML
appId: $APP_ID
---
- inputText: "$marker"
YAML
)"
if run_maestro_flow_allow_failure "$flow_file"; then
log "Maestro inputText completed; checking that terminal did not receive $marker"
else
log "Maestro inputText had no focused terminal target; checking output anyway"
fi
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "FAIL: terminal received no-focus probe $marker"
return 1
fi
log "PASS: terminal did not receive no-focus probe $marker"
}
# Default per-flow setup: find the visible terminal and optionally seed it.
# Callers can override NATIVE_TERMINAL_ID before sourcing.
: "${NATIVE_TERMINAL_ID:=}"
require_terminal_id() {
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
NATIVE_TERMINAL_ID="$(visible_terminal_id)"
fi
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
log "ERROR: no visible active terminal found"
return 1
fi
log "Using terminal $NATIVE_TERMINAL_ID"
}

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding shell history for history-arrows flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_HISTORY_OK-1" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-history-arrows.yaml"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding scrollback for scroll-no-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
# Emit a top marker, a screenful of filler, and a bottom marker so scrolling
# up reveals the top marker while the bottom marker is initially visible.
seed_command='{ echo SCROLL_TOP_MARKER; for i in $(seq 1 80); do echo filler-$i; done; echo SCROLL_BOTTOM_MARKER; }'
send_to_terminal "$NATIVE_TERMINAL_ID" "$seed_command" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-scroll-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SCROLL_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding selectable text for selection-drag flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SELECT_DRAG_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-selection-drag-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SELECTION_DRAG_SHOULD_NOT_TYPE_$RANDOM"
log "Reading simulator clipboard"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("SELECT_DRAG_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"SELECT_DRAG_OK"*) ]]; then
log "PASS: Copy wrote selected terminal marker text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal marker text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Ensuring terminal is visible for sidebar-swipe flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SIDEBAR_SWIPE_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-sidebar-swipe-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SIDEBAR_SWIPE_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Clearing terminal for tap-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-tap-focus-keyboard.yaml"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TAP_FOCUS_OK"

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding terminal virtual keyboard UX flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "printf '\n\n\n\nMAESTRO_COPY_OK\n'" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_VK_HISTORY_OK" Enter
set_simulator_clipboard "echo MAESTRO_PASTE_OK"
run_maestro_flow "$MAESTRO_DIR/native-terminal-virtual-keyboard-ux.yaml"
assert_terminal_output_count_at_least "$NATIVE_TERMINAL_ID" "MAESTRO_VK_HISTORY_OK" 2
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TOGGLE_INPUT_OK"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_PASTE_OK"
log "Reading simulator clipboard after Copy"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("MAESTRO_COPY_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"MAESTRO_COPY_OK"*) ]]; then
log "PASS: Copy wrote selected terminal text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,29 @@
appId: sh.paseo.debug
---
# Self-contained history/arrows flow.
# Precondition: the harness has run `echo MAESTRO_HISTORY_OK-1` in the
# terminal, so shell history contains the command.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The seeded command output should already be visible.
- assertVisible:
text: "MAESTRO_HISTORY_OK-1"
# Tap the virtual up arrow to recall the last command.
- tapOn:
id: "terminal-key-up"
# Submit it again.
- tapOn:
id: "terminal-key-enter"
# The command ran a second time, so the marker is still visible.
- extendedWaitUntil:
visible: "MAESTRO_HISTORY_OK-1"
timeout: 15000
- takeScreenshot: native-terminal-history-arrows

View File

@@ -0,0 +1,38 @@
appId: sh.paseo.debug
---
# Self-contained scroll-no-focus flow.
# Precondition: the harness has filled scrollback with a top marker, filler
# lines, and a bottom marker.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The bottom marker should be visible before scrolling.
- assertVisible:
text: "SCROLL_BOTTOM_MARKER"
# Scroll up in history by dragging downward across the terminal surface.
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
# Scrolling should reveal the older top marker without focusing the terminal.
- assertVisible:
text: "SCROLL_TOP_MARKER"
- assertVisible:
id: "terminal-follow-bottom"
# The scroll gesture must not show the OS keyboard. The harness also probes with
# Maestro inputText and fails if text reaches the terminal after this gesture.
- assertNotVisible: "return"
- takeScreenshot: native-terminal-scroll-no-focus

View File

@@ -0,0 +1,45 @@
appId: sh.paseo.debug
---
# Self-contained long-press selection flow.
# Precondition: the harness has printed SELECT_DRAG_OK in the terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Selection starts with long press.
- longPressOn:
point: "1%,21%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
# Once selection is active, horizontal drag extends selection instead of opening
# navigation or focusing the OS keyboard.
- swipe:
start: "1%,21%"
end: "100%,21%"
duration: 1200
- waitForAnimationToEnd
# The active-selection drag must not open the sidebar.
- assertNotVisible:
id: "sidebar-sessions"
# The active-selection drag must not show the OS keyboard. The harness also
# probes with Maestro inputText and fails if text reaches the terminal after
# this gesture.
- assertNotVisible: "return"
# Copy remains usable after extending the selection.
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-selection-drag
# The harness will read the simulator clipboard and verify SELECT_DRAG_OK.

View File

@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
# Self-contained sidebar-swipe flow.
# Precondition: the harness has ensured the terminal is visible and the
# sidebar is closed.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Sidebar should start closed.
- assertNotVisible:
id: "sidebar-sessions"
# Horizontal swipe from the terminal surface opens navigation. This is
# intentionally not speed-based and not a right-edge-only gesture.
- swipe:
start: "25%,50%"
end: "80%,50%"
duration: 900
- waitForAnimationToEnd
- assertVisible:
id: "sidebar-sessions"
# If the OS keyboard appears, iOS exposes a lowercase return key. This assertion
# is a UI-level guard; the harness also probes with Maestro inputText and fails
# if text reaches the terminal after the swipe.
- assertNotVisible: "return"
# Close the sidebar to leave a clean state.
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- takeScreenshot: native-terminal-sidebar-swipe

View File

@@ -0,0 +1,31 @@
appId: sh.paseo.debug
---
# Self-contained tap-focus flow.
# Precondition: the harness has cleared the visible terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
# First focus and hide the OS keyboard. The regression this covers was a hidden
# keyboard with the native TextInput still focused, making the next focus() a
# no-op.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- hideKeyboard
- waitForAnimationToEnd
# Tap terminal content again, then type through Maestro's OS-keyboard route.
# Product Paste and CLI terminal input are deliberately not used here.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TAP_FOCUS_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TAP_FOCUS_OK"
timeout: 15000
- takeScreenshot: native-terminal-tap-focus

View File

@@ -0,0 +1,109 @@
appId: sh.paseo.debug
---
# Self-contained terminal virtual keyboard UX flow.
# Precondition: the harness has opened the visible terminal, seeded shell history,
# and set the simulator clipboard.
- assertVisible:
id: "terminal-virtual-keyboard"
# Space and Backspace are redundant with the OS keyboard and should not be part
# of the virtual keyboard surface.
- assertNotVisible:
id: "terminal-key-space"
- assertNotVisible:
id: "terminal-key-backspace"
# Copy is selection-only now; it should not be a permanent virtual key.
- assertNotVisible:
id: "terminal-copy"
# Arrow cluster controls are present. Up sits over Down in product layout; this
# flow verifies the actionable controls stay addressable and working.
- assertVisible:
id: "terminal-key-up"
- assertVisible:
id: "terminal-key-left"
- assertVisible:
id: "terminal-key-down"
- assertVisible:
id: "terminal-key-right"
- assertVisible:
id: "terminal-key-enter"
# Up + Enter reruns the seeded shell-history command.
- assertVisible:
text: "MAESTRO_VK_HISTORY_OK"
- tapOn:
id: "terminal-key-up"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_VK_HISTORY_OK"
timeout: 15000
# Keyboard toggle: start hidden, show, hide, show again. After the second show,
# Maestro inputText must reach the PTY/output.
- hideKeyboard
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- assertNotVisible: "return"
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TOGGLE_INPUT_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TOGGLE_INPUT_OK"
timeout: 15000
# Paste remains available in the virtual keyboard and sends clipboard text.
- assertVisible:
id: "terminal-paste"
enabled: true
- tapOn:
id: "terminal-paste"
- runFlow:
when:
visible: "Allow Paste"
commands:
- tapOn: "Allow Paste"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_PASTE_OK"
timeout: 15000
# Long-press selection makes Copy appear as a floating action; dragging while
# selected extends the selection without opening navigation or the OS keyboard.
- hideKeyboard
- waitForAnimationToEnd
- assertVisible:
text: "MAESTRO_COPY_OK"
- longPressOn:
point: "1%,28%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
- swipe:
start: "1%,28%"
end: "100%,28%"
duration: 1200
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- assertNotVisible: "return"
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-virtual-keyboard-ux

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.100",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {

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";
@@ -76,6 +74,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
@@ -105,6 +104,7 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
@@ -467,26 +467,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 +865,8 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
@@ -901,7 +891,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>

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 />;
}

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)} />;
}

View File

@@ -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");
});
});

View File

@@ -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),
};
}

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,
});

View File

@@ -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,
});
});
});

View File

@@ -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),
};
}

View File

@@ -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;

View File

@@ -1,33 +1,24 @@
import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
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;
@@ -50,6 +41,16 @@ function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function formatSessionCost(value: number): string | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
@@ -74,108 +75,38 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
return {
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
serverId,
provider,
pending = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage();
}
},
[refreshProviderUsage],
);
const percentage = getUsagePercentage(maxTokens, usedTokens);
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 = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
const center = showPercentage ? COMPACT_CENTER : CENTER;
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
return (
<Tooltip
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
@@ -231,7 +162,6 @@ export function ContextWindowMeter({
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View>
</TooltipContent>
</Tooltip>
@@ -262,15 +192,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],
minWidth: 200,
gap: theme.spacing[1],
},
tooltipTitle: {
color: theme.colors.foreground,

View File

@@ -1,9 +0,0 @@
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}

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.

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}>

View File

@@ -1,5 +1,4 @@
import * as Clipboard from "expo-clipboard";
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,7 +20,6 @@ import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -250,7 +248,6 @@ function DiagnosticSubSheet({
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -291,62 +288,31 @@ function DiagnosticSubSheet({
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
}, [diagnostic, t, toast]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<View style={sheetStyles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
@@ -600,20 +566,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(() => {
@@ -774,11 +733,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},

File diff suppressed because it is too large Load Diff

View File

@@ -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,
},
}));

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -20,12 +21,45 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -40,6 +74,17 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
@@ -275,18 +320,448 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
if (!hydratedWorkspace) return null;
return (
<SidebarWorkspaceRow
<StatusWorkspaceRowWithMenu
workspace={hydratedWorkspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -350,4 +825,37 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -22,15 +22,14 @@ 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 WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
@@ -112,10 +111,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 +133,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>
@@ -225,9 +220,7 @@ function WorkspaceStatusIndicator({
);
}
if (bucket === "done") {
return <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
}
if (bucket === "done") return null;
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
@@ -510,7 +503,7 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: theme.iconSize.md,
width: WORKSPACE_STATUS_DOT_WIDTH,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,

View File

@@ -1,641 +0,0 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Theme } from "@/styles/theme";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function noop() {}
interface SidebarWorkspaceRowProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
onPress: () => void;
/** Secondary line under the name (status grouping shows the project name). */
subtitle?: string | null;
/** Project grouping only: shows a transient "creating" affordance. */
isCreating?: boolean;
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
onPress,
subtitle,
isCreating = false,
drag,
isDragging = false,
dragHandleProps,
}: SidebarWorkspaceRowProps) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) {
return;
}
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
subtitle={subtitle}
isCreating={isCreating}
isArchiving={isArchiving}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={archiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
interface WorkspaceRowBodyProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
subtitle?: string | null;
isCreating: boolean;
isArchiving: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspaceRowBody({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
subtitle,
isCreating,
isArchiving,
onPress,
drag,
isDragging,
dragHandleProps,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag: drag ?? noop,
menuController: null,
});
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
{...(draggable ? dragHandleProps?.listeners : {})}
ref={
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={draggable ? interaction.handlePressIn : undefined}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={draggable ? interaction.handlePressOut : undefined}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={subtitle}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<WorkspaceRowTrailingActions
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
/>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function WorkspaceRowTrailingActions({
workspace,
isHovered,
isTouchPlatform,
isCreating,
showShortcutBadge,
shortcutNumber,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
onArchive,
onMarkAsRead,
onCopyBranchName,
onCopyPath,
onRename,
}: {
workspace: SidebarWorkspaceEntry;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
onArchive?: () => void;
onMarkAsRead?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null}
</>
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getWorkspaceRowStyle({
isDragging,
selected,
isHovered,
}: {
isDragging: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
const styles = StyleSheet.create((theme) => ({
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -1,223 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import type { useContextMenu } from "@/components/ui/context-menu";
export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
const didStartDragRef = useRef(false);
const scrollIntentRef = useRef(false);
const menuOpenedRef = useRef(false);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current);
dragArmTimerRef.current = null;
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current);
contextMenuTimerRef.current = null;
}
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
}, []);
useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
const armTimers = useCallback(() => {
clearTimers();
const DRAG_ARM_DELAY_MS = 180;
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
const CONTEXT_MENU_DELAY_MS = 450;
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
dragArmTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
return;
}
dragArmedRef.current = true;
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || platformIsWeb) {
return;
}
contextMenuTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
return;
}
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
if (!dragActivatedRef.current) {
return;
}
didStartDragRef.current = true;
didLongPressRef.current = true;
clearTimers();
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
},
[clearTimers],
);
const handleScrollIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true;
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handleSwipeIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
didLongPressRef.current = false;
dragArmedRef.current = false;
dragActivatedRef.current = false;
didStartDragRef.current = false;
scrollIntentRef.current = false;
menuOpenedRef.current = false;
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
armTimers();
},
[armTimers],
);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
const start = touchStartRef.current;
if (!start || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
const current = { x, y };
touchCurrentRef.current = current;
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const decision = decideLongPressMove({
dragArmed: dragArmedRef.current,
didStartDrag: didStartDragRef.current,
startPoint: start,
currentPoint: current,
});
if (decision === "vertical_scroll") {
handleScrollIntent({ dx, dy, distance });
return;
}
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
handleSwipeIntent({ dx, dy, distance });
return;
}
if (decision === "start_drag") {
handleDragIntent({ dx, dy, distance });
}
},
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
);
const handlePressOut = useCallback(() => {
clearTimers();
dragArmedRef.current = false;
dragActivatedRef.current = false;
touchStartRef.current = null;
touchCurrentRef.current = null;
}, [clearTimers]);
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handleTouchMove,
handlePressOut,
};
}

View File

@@ -0,0 +1,141 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export interface TerminalPasteActionProps {
hasClipboardText: boolean;
onPaste: () => void;
}
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
return (
<TerminalActionButton
label="Paste"
accessibilityLabel="Paste"
testID="terminal-paste"
disabled={!hasClipboardText}
onPress={onPaste}
variant="key"
/>
);
}
export interface TerminalFloatingCopyActionProps {
hasSelection: boolean;
onCopy: () => void;
}
export function TerminalFloatingCopyAction({
hasSelection,
onCopy,
}: TerminalFloatingCopyActionProps) {
if (!hasSelection) {
return null;
}
return (
<TerminalActionButton
label="Copy"
accessibilityLabel="Copy"
testID="terminal-copy"
onPress={onCopy}
variant="floating"
/>
);
}
interface TerminalActionButtonProps {
label: string;
accessibilityLabel: string;
testID: string;
disabled?: boolean;
onPress: () => void;
variant: "key" | "floating";
}
function TerminalActionButton({
label,
accessibilityLabel,
testID,
disabled = false,
onPress,
variant,
}: TerminalActionButtonProps) {
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
variant === "floating" ? styles.floatingButton : styles.keyButton,
disabled && styles.keyButtonDisabled,
(Boolean(hovered) || pressed) &&
!disabled &&
(variant === "floating" ? styles.floatingButtonHovered : styles.keyButtonHovered),
],
[disabled, variant],
);
const textStyle = useMemo(
() => [variant === "floating" ? styles.floatingButtonText : styles.keyButtonText],
[variant],
);
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
const handlePress = useCallback(() => {
if (!disabled) {
onPress();
}
}, [disabled, onPress]);
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={disabled}
testID={testID}
onPress={handlePress}
style={pressableStyle}
>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
keyButton: {
flex: 1,
minWidth: 0,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
keyButtonHovered: {
backgroundColor: theme.colors.surface2,
},
keyButtonDisabled: {
opacity: 0.45,
},
keyButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
floatingButton: {
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
backgroundColor: "rgba(32, 32, 32, 0.86)",
},
floatingButtonHovered: {
backgroundColor: "rgba(48, 48, 48, 0.92)",
},
floatingButtonText: {
color: "#f5f5f5",
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
}));

File diff suppressed because it is too large Load Diff

View File

@@ -24,10 +24,12 @@ import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -47,7 +49,10 @@ export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
showKeyboard: () => void;
blur: () => void;
}
@@ -136,13 +141,19 @@ interface TerminalEmulatorProps {
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
keyboardInset?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onResize?: (input: {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -152,6 +163,7 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onSelectionChange?: (hasSelection: boolean) => void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
@@ -292,6 +304,18 @@ export default function TerminalEmulator({
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
const pasteText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
mountCallbacksRef.current.onInput?.(
encodeTerminalPaste({
text,
bracketedPaste: runtimeRef.current?.getInputModeState().bracketedPaste ?? false,
}),
);
}, []);
useDOMImperativeHandle(
domBridgeRef,
(): DOMImperativeFactory => ({
@@ -311,14 +335,25 @@ export default function TerminalEmulator({
runtimeRef.current?.renderSnapshot({ state });
}
},
paste: (...args) => {
const text = args[0];
if (typeof text === "string" && text.length > 0) {
pasteText(text);
}
},
copySelection: async () => "",
clear: () => {
runtimeRef.current?.clear();
},
showKeyboard: () => {
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useImperativeHandle(
ref,
@@ -332,14 +367,22 @@ export default function TerminalEmulator({
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => {
pasteText(text);
},
copySelection: async () => "",
clear: () => {
runtimeRef.current?.clear();
},
showKeyboard: () => {
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import {
ActivityIndicator,
Pressable,
@@ -8,8 +9,12 @@ import {
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
} from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -17,9 +22,22 @@ import { useAppVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import {
createTerminalKeyInput,
dispatchTerminalKeyInput,
EMPTY_TERMINAL_KEY_MODIFIERS,
type TerminalKeyModifierState,
} from "@/terminal/runtime/terminal-key-dispatch";
import {
getTerminalVirtualKeyboardControlId,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
TerminalStreamController,
@@ -30,7 +48,9 @@ import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
shouldReplayTerminalSnapshotForRenderer,
@@ -67,23 +87,9 @@ const MODIFIER_LABELS = {
alt: "Alt",
} as const;
const KEY_BUTTONS = {
esc: { id: "esc", label: "Esc", key: "Escape" },
tab: { id: "tab", label: "Tab", key: "Tab" },
up: { id: "up", label: "↑", key: "ArrowUp" },
down: { id: "down", label: "↓", key: "ArrowDown" },
left: { id: "left", label: "←", key: "ArrowLeft" },
right: { id: "right", label: "→", key: "ArrowRight" },
enter: { id: "enter", label: "Enter", key: "Enter" },
backspace: { id: "backspace", label: "⌫", key: "Backspace" },
space: { id: "space", label: "Space", key: " " },
} as const;
const EMPTY_MODIFIERS = EMPTY_TERMINAL_KEY_MODIFIERS;
interface ModifierState {
ctrl: boolean;
shift: boolean;
alt: boolean;
}
type ModifierState = TerminalKeyModifierState;
type PendingTerminalInput =
| {
@@ -92,21 +98,9 @@ type PendingTerminalInput =
}
| {
type: "key";
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
};
input: TerminalKeyInput;
};
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
alt: false,
};
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -161,6 +155,40 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps
);
}
interface KeyboardToggleButtonProps {
isKeyboardVisible: boolean;
iconColor: string;
onToggle: () => void;
}
function KeyboardToggleButton({
isKeyboardVisible,
iconColor,
onToggle,
}: KeyboardToggleButtonProps) {
const label = isKeyboardVisible ? "Hide keyboard" : "Show keyboard";
const Icon = isKeyboardVisible ? KeyboardOffIcon : KeyboardIcon;
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[],
);
return (
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
testID="terminal-keyboard-toggle"
onPress={onToggle}
style={pressableStyle}
>
<Icon color={iconColor} size={16} />
</Pressable>
);
}
export function TerminalPane({
serverId,
cwd,
@@ -182,11 +210,12 @@ export function TerminalPane({
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const swipeGesturesEnabled = isMobile;
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -210,14 +239,14 @@ export function TerminalPane({
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const inputModeRef = useRef<TerminalInputModeState>({
kittyKeyboardFlags: 0,
win32InputMode: false,
});
const inputModeRef = useRef<TerminalInputModeState>(DEFAULT_TERMINAL_INPUT_MODE_STATE);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
@@ -226,12 +255,39 @@ export function TerminalPane({
useEffect(() => {
terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
inputModeRef.current = DEFAULT_TERMINAL_INPUT_MODE_STATE;
setHasSelection(false);
}, [terminalId]);
const refreshClipboardAvailability = useCallback(async () => {
if (!isMobile) {
setHasClipboardText(false);
return;
}
try {
const hasText = await Clipboard.hasStringAsync();
setHasClipboardText(hasText);
} catch {
setHasClipboardText(false);
}
}, [isMobile]);
useEffect(() => {
void refreshClipboardAvailability();
}, [refreshClipboardAvailability, isAppVisible]);
useEffect(() => {
void refreshClipboardAvailability();
}, [keyboardInset, refreshClipboardAvailability]);
useEffect(() => {
setIsKeyboardToggleVisible(keyboardInset > 0);
}, [keyboardInset]);
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
setHasSelection(nextHasSelection);
}, []);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -332,19 +388,27 @@ export function TerminalPane({
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
const handleKeyboardInsetChange = useCallback(
(nextInset: number) => {
setKeyboardInset(nextInset);
pulseKeyboardRefits();
},
[pulseKeyboardRefits],
);
useEffect(() => {
return () => clearKeyboardRefitTimeouts();
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => isMobile && keyboardShift.value > 0,
() => (isMobile ? Math.round(keyboardShift.value) : 0),
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
runOnJS(handleKeyboardInsetChange)(next);
},
[isMobile, pulseKeyboardRefits],
[isMobile, handleKeyboardInsetChange],
);
useEffect(() => {
@@ -480,15 +544,14 @@ export function TerminalPane({
return true;
}
const encoded = encodeTerminalKeyInput(entry.input, {
dispatchTerminalKeyInput({
keyInput: entry.input,
inputMode: inputModeRef.current,
});
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(currentTerminalId, {
type: "input",
data: encoded,
sendData: (data) =>
client.sendTerminalInput(currentTerminalId, {
type: "input",
data,
}),
});
return true;
},
@@ -540,26 +603,31 @@ export function TerminalPane({
enqueuePendingTerminalInput({
type: "key",
input: {
key: normalizeTerminalTransportKey(input.key),
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
...createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
}),
},
});
return true;
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
const pendingEntry: PendingTerminalInput = {
type: "key",
input: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
input: createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
},
}),
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
@@ -626,7 +694,7 @@ export function TerminalPane({
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean }) => {
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
@@ -640,6 +708,7 @@ export function TerminalPane({
}
const previousSent = lastSentTerminalSizeRef.current;
if (
!input.forceClaim &&
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
@@ -666,6 +735,38 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
void pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
}).then(() => refreshClipboardAvailability());
}, [requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
?.copySelection({
writeText: async (text: string) => {
await Clipboard.setStringAsync(text);
},
})
.then(() => refreshClipboardAvailability());
}, [refreshClipboardAvailability]);
const handleKeyboardToggle = useCallback(() => {
if (isKeyboardToggleVisible) {
setIsKeyboardToggleVisible(false);
emulatorRef.current?.blur();
requestTerminalReflow();
return;
}
setIsKeyboardToggleVisible(true);
emulatorRef.current?.showKeyboard();
requestTerminalFocus();
requestTerminalReflow();
}, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
@@ -710,8 +811,9 @@ export function TerminalPane({
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
requestTerminalReflow();
},
[requestTerminalFocus],
[requestTerminalFocus, requestTerminalReflow],
);
const sendVirtualKey = useCallback(
@@ -725,6 +827,7 @@ export function TerminalPane({
});
clearPendingModifiers();
requestTerminalFocus();
requestTerminalReflow();
},
[
clearPendingModifiers,
@@ -732,6 +835,7 @@ export function TerminalPane({
modifiers.ctrl,
modifiers.shift,
requestTerminalFocus,
requestTerminalReflow,
sendTerminalKey,
],
);
@@ -752,6 +856,54 @@ export function TerminalPane({
emulatorRef.current?.blur();
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
const showPasteAction = shouldShowTerminalPasteAction({ isNative });
const showFloatingCopyAction = shouldShowTerminalFloatingCopyAction({
hasSelection,
isNative,
});
const keyboardToggleIconColor = theme.colors.foregroundMuted;
const renderVirtualKeyboardControl = (control: TerminalVirtualKeyboardControl) => {
const controlId = getTerminalVirtualKeyboardControlId(control);
switch (control.type) {
case "key":
return (
<VirtualKeyButton
key={controlId}
id={control.button.id}
label={control.button.label}
keyValue={control.button.key}
onSend={sendVirtualKey}
/>
);
case "modifier":
return (
<ModifierButton
key={controlId}
modifier={control.modifier}
active={modifiers[control.modifier]}
onToggle={toggleModifier}
/>
);
case "paste":
return showPasteAction ? (
<TerminalPasteAction
key={controlId}
hasClipboardText={hasClipboardText}
onPaste={handleTerminalPaste}
/>
) : null;
case "keyboardToggle":
return (
<KeyboardToggleButton
key={controlId}
iconColor={keyboardToggleIconColor}
isKeyboardVisible={isKeyboardToggleVisible}
onToggle={handleKeyboardToggle}
/>
);
}
};
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused,
hasStreamError: Boolean(streamError),
@@ -782,6 +934,7 @@ export function TerminalPane({
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
keyboardInset={keyboardInset}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -792,6 +945,7 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
@@ -809,6 +963,12 @@ export function TerminalPane({
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
{showFloatingCopyAction ? (
<View pointerEvents="box-none" style={styles.floatingCopyContainer}>
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
</View>
) : null}
</View>
{streamError ? (
@@ -822,55 +982,14 @@ export function TerminalPane({
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<View style={styles.keyboardRows}>
<View style={styles.keyboardRow}>
{[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
<ModifierButton modifier="ctrl" active={modifiers.ctrl} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.up.id}
label={KEY_BUTTONS.up.label}
keyValue={KEY_BUTTONS.up.key}
onSend={sendVirtualKey}
/>
<ModifierButton modifier="shift" active={modifiers.shift} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.backspace.id}
label={KEY_BUTTONS.backspace.label}
keyValue={KEY_BUTTONS.backspace.key}
onSend={sendVirtualKey}
/>
</View>
<View style={styles.keyboardRow}>
<ModifierButton modifier="alt" active={modifiers.alt} onToggle={toggleModifier} />
{[
KEY_BUTTONS.space,
KEY_BUTTONS.left,
KEY_BUTTONS.down,
KEY_BUTTONS.right,
KEY_BUTTONS.enter,
].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
</View>
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
>
{row.map(renderVirtualKeyboardControl)}
</View>
))}
</View>
</View>
) : null}
@@ -900,6 +1019,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.16)",
},
floatingCopyContainer: {
position: "absolute",
right: theme.spacing[3],
bottom: theme.spacing[12],
zIndex: 2,
},
errorRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],

View File

@@ -95,9 +95,7 @@ async function callWorkspaceCreation({
worktreeSlug: createNameId(),
});
}
return connectedClient.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
return connectedClient.openProject(input.cwd);
}
function failureMessageForCreationMethod(

View File

@@ -61,7 +61,6 @@ import {
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
interface AgentControlOption {
id: string;
@@ -1487,13 +1486,10 @@ export const AgentControls = memo(function AgentControls({
console.warn("[AgentControls] persist thinking preference failed", error);
});
}
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
},
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
);

View File

@@ -16,22 +16,13 @@ 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 +104,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 +144,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 +192,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 +260,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,27 +271,13 @@ 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).catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, client, slice?.provider, toast, updatePreferences],
[agentId, client, toast],
);
if (!slice || availableModes.length === 0) return null;
@@ -413,13 +356,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,
},
}));

View File

@@ -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();
});
});

View File

@@ -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";

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";
@@ -197,8 +196,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
provider: agent?.provider ?? null,
};
};
}
@@ -208,12 +205,8 @@ function renderContextWindowMeter(
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
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 (
@@ -222,9 +215,6 @@ function renderContextWindowMeter(
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending}
/>
);
}
@@ -1646,9 +1636,6 @@ export function Composer({
agentState.contextWindowUsedTokens,
);
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeter = useMemo(
() =>
renderContextWindowMeter(
@@ -1656,19 +1643,8 @@ export function Composer({
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
),
[
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
],
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
@@ -1747,15 +1723,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 +1837,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>
);
}

View File

@@ -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);
}

View File

@@ -71,7 +71,6 @@ import {
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import {
applyLegacyDaemonWorkspaceOwnership,
@@ -153,7 +152,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
workspaces.set(workspace.id, workspace);
}
// Project parents with no active workspaces only ride on the first page.
// Empty project parents only ride on the first page.
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
@@ -1952,13 +1951,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentMode skipped: daemon unavailable");
return;
}
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
void client.setAgentMode(agentId, modeId).catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);
@@ -1983,13 +1979,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
return;
}
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);

View File

@@ -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({});
});

View File

@@ -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 } : {}),
},
},
};
}

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",

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];

View File

@@ -377,7 +377,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
label: "Merge",
});
});
@@ -398,7 +398,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
label: "Merge",
});
});
@@ -458,7 +458,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
label: "Merge",
});
});
@@ -512,7 +512,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
label: "Merge",
});
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
});
@@ -583,7 +583,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-squash",
label: "Merge PR (squash)",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -592,7 +592,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-merge",
label: "Merge PR (merge)",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -601,7 +601,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-rebase",
label: "Merge PR (rebase)",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -703,10 +703,7 @@ describe("git-actions-policy", () => {
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Merge" });
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
@@ -747,7 +744,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "enable-pr-auto-merge-squash",
label: "Auto merge (squash)",
label: "Auto merge",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
@@ -765,41 +762,6 @@ describe("git-actions-policy", () => {
).toBe(false);
});
it.each([
["SQUASH", "enable-pr-auto-merge-squash", "Auto merge (squash)"],
["MERGE", "enable-pr-auto-merge-merge", "Auto merge (merge)"],
["REBASE", "enable-pr-auto-merge-rebase", "Auto merge (rebase)"],
] as const)(
"labels the %s auto-merge action with its method",
(viewerDefaultMergeMethod, id, label) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod,
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id, label });
},
);
it("does not offer auto-merge when the daemon feature gate is missing", () => {
const actions = buildGitActions(
createInput({
@@ -889,7 +851,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-merge",
label: "Merge PR (merge)",
label: "Merge",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",

View File

@@ -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,
},
}));

View File

@@ -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";

View File

@@ -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",
},
}));

View File

@@ -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,
},
}));

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",

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