Author SHA1 Message Date
jb 0c6ae449ea docs: research Pi sandbox requirements 2026-08-22 22:36:39 -04:00
Mohamed BoudraandGitHub a69dca2907 Expand workspace panel actions and keyboard navigation (#3685)
CI / changes (push) Successful in 6m6s
Docker / setup (push) Successful in 14s
Docker / publish (push) Skipped
Docker / build (push) Failing after 10m1s
CI / relay-tests (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / server-tests (ubuntu-latest) (push) Canceled after 0s
CI / server-tests (windows-latest) (push) Canceled after 0s
CI / desktop-tests (ubuntu-latest) (push) Canceled after 0s
CI / desktop-tests (windows-latest) (push) Canceled after 0s
CI / app-tests (push) Canceled after 0s
CI / sdk-tests (push) Canceled after 0s
CI / playwright (shard 1/4) (push) Canceled after 0s
CI / playwright (shard 2/4) (push) Canceled after 0s
CI / playwright (shard 3/4) (push) Canceled after 0s
CI / playwright (shard 4/4) (push) Canceled after 0s
CI / format (push) Canceled after 0s
CI / cli-tests (shard 1/3) (push) Canceled after 0s
CI / cli-tests (shard 2/3) (push) Canceled after 0s
CI / cli-tests (shard 3/3) (push) Canceled after 0s
* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
2026-08-22 14:15:23 +02:00
Mohamed BoudraandGitHub 3029b5991a Surface workspace changes from agent panes (#3682)
* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes
2026-08-22 12:20:21 +02:00
Mohamed BoudraandGitHub b1455fb4b3 docs(hub): document zero-friction onboarding (#3657)
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
2026-08-22 12:15:58 +02:00
Mohamed BoudraandGitHub b4e0ff7685 fix(app): restore native composer auto-growth (#3681)
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
2026-08-22 11:36:22 +02:00
Mohamed BoudraandGitHub 4c6b79dc44 feat(app): refine agent profile creation (#3680)
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
2026-08-22 11:16:28 +02:00
Mohamed BoudraandGitHub a687c42f33 Keep released agent SDK versions reproducible (#3678)
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
2026-08-22 11:10:20 +02:00
Mohamed BoudraandGitHub 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00
Mohamed BoudraandGitHub c763b80824 fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
2026-08-22 10:13:31 +02:00
Mohamed BoudraandGitHub 8905ad4160 fix(relay): validate handshake keys (#3669) 2026-08-22 02:32:13 +02:00
60a5344cc6 fix(server): stop short start gates from undercutting provider startup budgets (#3621)
* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 02:06:16 +02:00
92 changed files with 3716 additions and 561 deletions
+5 -1
View File
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it.
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## The name
+285
View File
@@ -0,0 +1,285 @@
# Pi sandbox requirements
Research scope: Pi `0.84.2`, Paseo's current direct Pi provider, and `pi-mcp-adapter` `2.27.0`. This note defines the smallest useful contract for a real `pi --mode rpc` process under gVisor. It covers the host-installed daemon and the official Paseo Docker image. It does not choose a sandbox launcher or implement the contract.
## Findings
### Process and image contents
Paseo spawns Pi as a JSONL child process with piped stdin, stdout, and stderr. A normal launch has this shape:
```text
pi --mode rpc \
--model <provider/model> \
--thinking <level> \
[--session <absolute-jsonl-path> | --no-session] \
[--mcp-config <generated-file>] \
--extension <generated-paseo-extension>
```
Paseo sets the child cwd to the agent cwd and forwards the daemon environment plus provider overrides and `PASEO_AGENT_ID` and `PASEO_AGENT_CWD`. Pi adds `AI_AGENT=pi` and `PI_CODING_AGENT=true` for its descendants. Commands launched by Pi also receive `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`.[^paseo-launch] [^paseo-env] [^pi-env]
A minimum npm-based image needs:
| Content | Requirement |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Linux userspace | A glibc image such as Debian bookworm slim. The official Pi container example uses `node:24-bookworm-slim`.[^pi-container] |
| Node | Node `>=22.19.0`, matching Pi's package engine. Copying only the `pi` script without Node and its package tree will not work.[^pi-package] |
| Pi | The complete installed `@earendil-works/pi-coding-agent` package and its production dependency tree, or an official standalone Pi binary plus its adjacent assets. Pin the version in the sandbox image. |
| TLS | `ca-certificates` and working DNS. Provider requests use HTTPS unless the selected model has a local or custom HTTP base URL. |
| Shell | `bash`. Pi's built-in bash tool launches a configured shell, and the official Pi container installs bash.[^pi-container] [^pi-bash] |
| Search helpers | Preinstall `rg` and `fd`/`fdfind`. Pi's `grep` and `find` tools otherwise download them from GitHub into `<agent-dir>/bin`, adding writable state and broad GitHub egress.[^pi-tools] |
| Project tooling | `git` is part of Pi's official container recommendation and is needed for normal coding-agent work. Add only the compilers, package managers, SSH client, and project CLIs that the session is allowed to invoke. Pi does not provide them.[^pi-container] |
| MCP adapter | Install and pin `pi-mcp-adapter` plus its production dependencies if the session must receive Paseo tools over MCP. Pi has no built-in MCP client.[^pi-readme] [^mcp-package] |
`pi-mcp-adapter` ships TypeScript source and Pi loads it through its extension loader. Copy its complete installed package tree, including the platform package for `@napi-rs/keyring`. Paseo disables both adapter OAuth and automatic auth for its own local HTTP server, so the local Paseo MCP path does not need an OS keyring. Other MCP servers that use OAuth or `bearerTokenStore` need a functioning OS credential store; the adapter fails closed when it cannot access one. Headless Linux usually needs a Secret Service session, and its keyring recovery path also expects `keyctl` and `node`.[^mcp-auth]
The image should set these defaults:
```text
HOME=/home/sandbox
PI_CODING_AGENT_DIR=/state/pi-agent
PI_CODING_AGENT_SESSION_DIR=/state/pi-sessions
PI_OFFLINE=1
PATH=<fixed image paths plus explicitly allowed project tool paths>
TMPDIR=/tmp
```
`PI_OFFLINE=1` removes Pi's startup catalog/update traffic. It does not block model calls. Pi RPC otherwise starts a background model-catalog refresh. Disabling this traffic makes a network allowlist predictable; bake the required model catalog or project `models.json` into the state projection.[^pi-offline] [^pi-rpc-refresh]
### Files Pi reads
Pi resolves global state from `PI_CODING_AGENT_DIR`, defaulting to `~/.pi/agent`. Pointing it at a dedicated sandbox directory avoids any need to mount the daemon user's home.[^pi-paths]
| Path | Access | Required for the minimum contract | Treatment |
| ----------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<agent-dir>/auth.json` | Read and sometimes write | One credential source, unless credentials arrive through environment variables or `--api-key` | Project only the selected provider credential. Keep writable when it contains OAuth because Pi refreshes and rewrites tokens under a file lock. API-key-only sessions can use a secret environment variable and omit the file.[^pi-providers] [^pi-auth-storage] |
| `<agent-dir>/settings.json` | Read | A small generated file | Copy only settings that change runtime behavior: proxy, custom session directory, model defaults not supplied by Paseo, and the pinned MCP adapter package/resource declaration. Avoid copying unrelated extension/package declarations. |
| `<agent-dir>/models.json` | Read | Only for custom providers/models | Copy read-only when the selected model depends on it. It may contain inline keys or command-backed key resolution, so treat it as a credential-bearing file.[^pi-providers] |
| `<agent-dir>/models-store.json` | Read/write cache | Only for a dynamic catalog or offline cached model absent from the built-in catalog | Seed narrowly and permit cache writes, or omit when the selected model is built in. Pi stores refreshed catalogs here.[^pi-providers] [^pi-model-store] |
| `<agent-dir>/trust.json` | Read | Only if project `.pi` settings/resources must load in non-interactive RPC mode | Copy a narrow decision for the mounted cwd, or set a generated global `defaultProjectTrust`. Do not inherit trust decisions for unrelated host paths.[^pi-trust] |
| `<agent-dir>/mcp.json` | Read by Paseo before launch and by the adapter | Optional user-global MCP settings | Paseo merges it into the generated per-session MCP config. Copy only MCP entries approved for this sandbox. |
| `<agent-dir>/mcp-cache.json` | Read/write | Adapter metadata optimization, direct-tool bootstrap | Persist in sandbox state if direct MCP tools or cached metadata matter. It contains tool metadata and config hashes, not the Paseo bearer token.[^mcp-cache] |
| `<agent-dir>/mcp-npx-cache.json` and `agent-plugin-data/` | Read/write | Only for configured npx stdio servers or Agent Plugins | Persist only when those features are enabled. Their executables and data expand the sandbox contract. |
| `<agent-dir>/extensions`, `skills`, `prompts`, `themes`, `npm`, `git` | Read; package manager may write | Only resources selected for the session | Bake pinned resources into the image read-only or copy an allowlisted subset. Pi extensions execute arbitrary code. Do not project the daemon user's complete package trees.[^pi-resources] [^pi-packages] |
| `<agent-dir>/AGENTS.md`, `SYSTEM.md`, `APPEND_SYSTEM.md` | Read | Optional global prompt behavior | Copy only when Paseo promises parity with the user's Pi configuration. |
| `<cwd>/.pi/*`, `<cwd>/.mcp.json`, `<cwd>/.agents/skills`, context files | Read, and project tools may write under cwd | Depends on project trust | These arrive through the workspace mount. RPC ignores project resources unless trust resolves to yes; `ask` cannot open a trust prompt in RPC mode.[^pi-trust] |
Pi also walks from cwd toward the filesystem root for `AGENTS.md`, `AGENTS.override.md`, or `CLAUDE.md`. A workspace-only mount will omit instructions stored above the workspace. The mount planner must either project the approved parent context files at their original paths or declare that sandboxed sessions load workspace-contained context only.[^pi-readme]
### State that must persist
Persist these objects independently instead of persisting a synthetic home directory:
1. **Pi session JSONL.** Normal Paseo Pi agents omit `--no-session`; Pi writes under `<session-dir>/<encoded-cwd>/`. Paseo stores the absolute JSONL path as `nativeHandle`, resumes with `--session <path>`, and scans the configured session directory for imports. The daemon and later sandboxes must see the same stable absolute path.[^pi-sessions] [^paseo-resume] [^paseo-import]
2. **Mutable provider credentials.** Pi writes refreshed OAuth tokens back to `auth.json`. API keys delivered by environment do not require persistent credential files.[^pi-auth-storage]
3. **Adapter metadata caches** when direct tools or fast lazy discovery matter. They can be discarded at the cost of reconnecting and repopulating metadata.[^mcp-cache]
4. **Optional MCP secure-store state.** Only non-Paseo MCP servers using OAuth or adapter bearer storage need this. OS credential-store state is outside `<agent-dir>` and cannot be preserved by copying files alone.[^mcp-auth]
Keep the rest ephemeral. Give each sandbox a private writable `/tmp` for Pi bash spill files, image materialization, generated tool output, and MCP output/resource files. The adapter documents that some oversized MCP text spill files are not automatically deleted, so destroy the sandbox temp filesystem after the session.[^mcp-output]
### Workspace and project mounts
Mount the agent cwd read-write at the **same absolute path** recorded by Paseo. Pi writes that cwd into the session header, Paseo uses it in persistence metadata, and resume launches use it as the process cwd. Path translation would break import filtering, parent context discovery, absolute links in history, and commands saved in the transcript.[^paseo-resume] [^pi-sessions]
The minimum project mount set is:
- the cwd tree, read-write;
- the linked worktree's actual Git metadata, when `.git` is a file or points outside cwd;
- any explicit external directories that the product chooses to grant;
- the persistent session and minimal agent-state mounts described above;
- the two generated Paseo launch files, read-only, at the paths passed on argv.
Do not follow workspace symlinks into arbitrary host paths by mounting their targets. Let missing external targets fail unless a separate policy grants them. For Git linked worktrees, resolve `.git`, `commondir`, and object alternates before launch. Mount only the required Git dirs. They need write access for normal branch, index, object, and ref operations; a read-only Git mount supports inspection only.
### Paseo-generated extension and MCP config
For every Pi create or resume, Paseo creates two host temp directories:
- `paseo-pi-extension-*/paseo-integration.mjs`, passed with `--extension`. It appends Paseo's per-agent and daemon system prompts and emits session-tree markers over Pi extension UI notifications.
- `paseo-pi-mcp-*/mcp.json`, passed with `--mcp-config` when Paseo detects `pi-mcp-adapter`. It starts from `<agent-dir>/mcp.json`, overlays injected servers, writes mode `0600`, and deletes the temp directory when the Pi session closes.[^paseo-generated]
The gVisor launcher must bind each file or its containing directory into the sandbox at the exact argv path. Do not share the daemon's `/tmp`. Mount these paths read-only. The generated MCP file contains the per-daemon-run Paseo bearer token in an `Authorization` header and must stay private to that sandbox. The generated extension can contain system-prompt text; change its creation mode to `0600` in the later implementation ticket.
Paseo detects adapter support by launching a scratch Pi RPC process for the same cwd and calling `get_commands`; it accepts an extension command named `mcp` with adapter source metadata. All Pi probes, including catalog fetches and this detection process, need the same image, state projection, cwd mounts, and sandbox policy as the real session. Otherwise the daemon can report a different provider capability than the session receives.[^paseo-detect]
For injected HTTP MCP servers, Paseo converts the entry to:
```json
{
"url": "http://<daemon-address>/mcp/agents?callerAgentId=<agent-id>",
"headers": { "Authorization": "Bearer <per-daemon-run-token>" },
"auth": false,
"oauth": false
}
```
The adapter still reads standard user and project MCP layers around `--mcp-config`. The minimum Paseo contract should include only the injected `paseo` server. Supporting arbitrary user MCP config adds every referenced executable, cwd, environment secret, socket, remote host, package cache, and credential store to the contract.[^paseo-mcp] [^mcp-config]
### Local MCP connectivity
The current address generation assumes Pi shares the daemon network namespace. A daemon listening on `0.0.0.0` becomes `127.0.0.1`; an IPv6 wildcard becomes `::1`. A gVisor sandbox with its own network namespace resolves those addresses to itself, so current injected MCP URLs will fail.[^paseo-mcp-address]
The network ticket must provide a sandbox-specific MCP route and pass that route into runtime MCP config generation. Acceptable shapes include:
- a narrow host-gateway listener reachable only from the sandbox network;
- a per-sandbox proxy that exposes only `/mcp/agents` and forwards to daemon loopback;
- a Unix socket transport after both Paseo and the adapter support it for this server.
Do not expose the daemon's full `6767` listener to the sandbox network if a route-specific proxy can avoid it. Keep the per-daemon bearer token even on the private route. The token authorizes agent-scoped operations and rotates when the daemon restarts.[^paseo-mcp-auth]
### Egress contract
A real turn needs these network capabilities:
| Destination | Required | Policy |
| ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Configured DNS resolver | Yes for hostname endpoints | Allow DNS only to the sandbox resolver. Do not allow arbitrary port 53 destinations. |
| Selected model `baseUrl` | Yes, unless the model is local | Resolve the exact effective provider/model after Pi reads `auth.json`, `models.json`, and provider-scoped environment. Allow its host and port, normally HTTPS 443. Custom providers make a static global hostname list incomplete.[^pi-providers] |
| Provider OAuth refresh/token endpoint | Only for subscription OAuth | Allow the selected provider's auth endpoints. A refreshed credential writes back to `auth.json`; read-only credentials will fail when tokens expire.[^pi-providers] |
| Paseo Agent MCP route | Yes when Paseo tools are enabled | Allow the one private daemon/proxy address and path. It is local connectivity, not Internet egress. |
| Other configured HTTP MCP servers | Only when explicitly enabled | Add each resolved MCP URL and its OAuth metadata/token endpoints. Paseo's injected server disables OAuth and needs no discovery.[^mcp-config] |
| Stdio MCP descendants | Only when explicitly enabled | No network by default, but the child server may need its own egress. Treat each command as a separate workload policy. |
| `pi.dev` | No for RPC operation | Set `PI_OFFLINE=1`. Pi's documented update and telemetry endpoints are not needed for model calls.[^pi-offline] |
| GitHub API/releases | No when `rg` and `fd` are baked in | Pi downloads missing search helpers from GitHub. Preinstall them.[^pi-tools] |
| npm and Git hosts | No when packages and MCP servers are pinned in the image | Disallow runtime package installation. `npx -y` MCP entries otherwise require registry and tarball egress and mutable npm caches. |
| Arbitrary destinations from `bash` or extensions | Product decision | A coding agent can call `curl`, package managers, or custom extension code. Provider-only egress requires network enforcement below the process, not prompt instructions. |
Pass proxy variables only if the selected policy requires them. Pi supports `HTTP_PROXY` and `HTTPS_PROXY`, and a global `httpProxy` setting applies both. Ensure `NO_PROXY` covers the private Paseo MCP endpoint or route it through a proxy that can reach the daemon.[^pi-env]
## Explicit minimum sandbox contract
A sandboxed Paseo Pi session is ready only when the launcher satisfies all of these conditions:
1. **Identity:** run as a non-root uid/gid with no host capabilities, no daemon Docker socket, and a private process, IPC, temp, and mount namespace.
2. **Image:** provide pinned Pi, Node `>=22.19.0`, production dependencies and assets, CA roots, bash, git, `rg`, and `fd`; add the pinned MCP adapter package when Paseo tools are enabled.
3. **Cwd:** mount the exact agent cwd read-write at the same absolute path. Add only required external Git metadata and separately approved paths.
4. **Agent state:** set `PI_CODING_AGENT_DIR` to a minimal host-visible state directory. Include only selected auth, settings, custom model files, and pinned resources. Do not mount `$HOME`, `/home/paseo`, `~/.pi`, `~/.ssh`, cloud config dirs, or the daemon's `$PASEO_HOME` wholesale.
5. **Sessions:** set `PI_CODING_AGENT_SESSION_DIR` to a stable, host-visible, writable directory and preserve absolute session paths across launches. Scope it by daemon user or tenant; scope further by agent if cross-session import is not required.
6. **Credentials:** prefer one selected-provider API key or a brokered secret. If Pi OAuth is required, give `auth.json` coordinated read-write persistence. Do not duplicate a mutable OAuth file into independent sandboxes without a refresh reconciliation design.
7. **Generated files:** mount only the generated Paseo extension and MCP config read-only at their argv paths. Destroy them after the child exits.
8. **MCP:** install the adapter, confirm its `mcp` command during a sandboxed probe, and give the sandbox a private route to `/mcp/agents`. Keep `auth: false`, `oauth: false`, and the per-run bearer header for the injected server.
9. **Network:** allow the private Paseo MCP route, DNS to the configured resolver, the selected model endpoint, and any required provider token-refresh endpoints. Deny other egress by default. Set `PI_OFFLINE=1` and bake helpers/packages.
10. **Lifecycle:** sandbox create, resume, import hydration, catalog probes, and adapter probes. Kill the process tree and remove ephemeral temp state when a Pi session closes.
11. **Limits:** cap pids, memory, CPU, disk, file descriptors, output, and wall time. Pi and extensions can spawn children; stdio MCP and bash tools add more processes.
## Daemon topology implications
### Host-installed daemon
The daemon can bind the host workspace and persistent Pi state paths directly into the sandbox. It still must translate daemon loopback MCP connectivity because sandbox loopback is separate. A host gateway or path-limited proxy should be created per sandbox. Keep host `$HOME` outside the mount table.
Host-installed Pi may come from npm, Bun's global package tree, a version manager, or a standalone binary. Do not bind the discovered host installation into the sandbox as the production design. Build a pinned runtime image so the process cannot traverse the host package manager's global tree and so native dependencies match the sandbox libc and architecture.
### Official Docker Paseo daemon
The published daemon image runs as uid/gid `1000:1000`, sets `HOME=/home/paseo`, stores Paseo and agent state under `/home/paseo`, mounts workspaces at `/workspace`, and intentionally omits every agent CLI.[^paseo-docker]
A sibling gVisor sandbox cannot assume that `/workspace` inside the daemon container names a host bind source. The daemon-side launcher needs mount provenance: named volume identity or the original bind source plus destination. Bind the same workspace volume into the sandbox at the same in-container absolute path. Apply the same rule to persistent Pi sessions and minimal agent state.
Do not pass the official `/home/paseo` volume through to the sandbox. It contains `$PASEO_HOME`, daemon identity and config, logs, all provider credentials, and unrelated agent state. Create separate Pi credential/session volumes or copy approved files into sandbox-specific state. The official base image's Node and utilities also do not satisfy a sibling sandbox; the sandbox image owns its own runtime.
The Docker deployment ticket must also decide who owns gVisor container creation. Mounting a Docker/containerd socket into the Paseo daemon grants host-level control and conflicts with the sandbox goal. Prefer a narrow launcher service or runtime API that validates image, mounts, network, limits, and argv.
## What to copy, project, or persist
| Item | Strategy |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Pi, Node, MCP adapter, CA roots, bash, git, `rg`, `fd` | Bake read-only into the image. |
| Workspace and required Git metadata | Bind explicit paths, cwd read-write, at stable absolute destinations. |
| Paseo integration extension and merged MCP config | Bind two generated files read-only for one process. |
| API keys | Inject as per-process secrets or provider env. Avoid broad inherited daemon env. |
| Pi OAuth `auth.json` | Use one coordinated writable credential store or a broker. Persist and protect it independently. |
| Pi JSONL sessions | Persist in a dedicated writable volume visible to daemon and future sandboxes. |
| Minimal settings, custom model catalog, trust decision | Generate or copy an allowlisted subset. Prefer read-only unless Pi must refresh its model store. |
| MCP metadata/npx caches | Persist only for enabled adapter features. They are rebuildable. |
| User extensions/packages/skills/prompts | Bake pinned approved resources or bind explicit read-only trees. Omit ambient resources by default. |
| `/tmp`, bash spill output, materialized images/MCP resources | Private writable ephemeral filesystem, destroyed on teardown. |
| `$HOME`, `/home/paseo`, `$PASEO_HOME`, `.ssh`, cloud credential directories | Never mount wholesale. Grant individual files, sockets, or brokered credentials only. |
## Remaining uncertainties
1. **MCP adapter ownership and version.** Paseo detects an ambient `pi-mcp-adapter` but does not install or pin it. The sandbox image ticket must choose a supported version and define its upgrade compatibility with Pi and Paseo.
2. **MCP route design.** Current loopback URL generation cannot cross a gVisor network namespace. The network ticket must select a route-specific proxy, gateway address, or future Unix socket transport and define token exposure.
3. **OAuth concurrency.** Pi can rewrite `auth.json` during token refresh. The credential ticket must define locking and reconciliation across simultaneous sandboxes and daemon-side provider probes.
4. **Project trust parity.** Decide whether sandboxed RPC sessions load project `.pi` resources and ancestor context files. A generated `defaultProjectTrust: "never"` is safer but changes current trusted-project behavior.
5. **User extensions and MCP servers.** The minimum contract includes the Paseo extension and Paseo MCP only. Supporting ambient user Pi packages or arbitrary MCP config changes image contents, mounts, egress, subprocess policy, and credential requirements.
6. **Git worktree mounts.** The mount ticket must specify how it resolves and grants linked-worktree gitdir, commondir, alternates, submodules, and LFS without exposing unrelated repositories.
7. **Official Docker launcher authority.** The deployment ticket must choose a launcher architecture that avoids granting the daemon an unrestricted container runtime socket.
8. **Path namespace and import UX.** Stable absolute paths simplify resume and import. If the product requires path remapping, Paseo needs an explicit host-to-sandbox path identity layer before sandboxing ships.
9. **OS credential store in gVisor.** Adapter OAuth for non-Paseo MCP servers depends on a keyring service and native binding. Test whether the chosen gVisor image and launcher can provide a scoped Secret Service session; otherwise disable that feature or broker it.
10. **Provider egress inventory.** Pi supports many built-in and custom providers. Generate policy from the effective selected model and credential type at launch; a hand-maintained host list will drift.
## Implications for follow-up tickets
- **Mount ticket:** introduce explicit mount descriptors for cwd, external Git metadata, persistent sessions, minimal agent state, generated launch files, and optional approved resources. Record Docker volume provenance.
- **Security ticket:** isolate uid, namespaces, temp, process tree, secrets, and resource limits; reject broad home, runtime-socket, and daemon-state mounts; make the generated extension mode `0600`.
- **Network ticket:** replace loopback MCP assumptions with a private sandbox route, generate provider-specific egress, enforce DNS policy, set offline mode, and keep package/helper downloads out of runtime.
- **Credential ticket:** select env injection, a credential broker, or a coordinated writable Pi auth store. Cover OAuth refresh and adapter keyring behavior.
- **Runtime image ticket:** pin Pi and adapter versions, bake their complete dependencies and assets, preinstall `rg`/`fd`, and test create, prompt, tool call, MCP call, close, resume, and import under both daemon topologies.
## Sources
Paseo repository sources use paths from this checkout. Upstream links point to the researched versions.
[^paseo-launch]: [`packages/server/src/server/agent/providers/pi/runtime.ts:75-139`](../../packages/server/src/server/agent/providers/pi/runtime.ts) and [`cli-runtime.ts:60-86`](../../packages/server/src/server/agent/providers/pi/cli-runtime.ts).
[^paseo-env]: [`packages/server/src/server/agent/agent-manager.ts:4767-4788`](../../packages/server/src/server/agent/agent-manager.ts) and [`paseo-env.ts:1-43`](../../packages/server/src/server/paseo-env.ts).
[^pi-env]: Pi 0.84.2, [Environment Variables](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/environment-variables.md) and [`README.md`, environment table](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#environment-variables).
[^pi-container]: Pi 0.84.2, [Containerization, Plain Docker](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/containerization.md#plain-docker).
[^pi-package]: Pi 0.84.2, [`packages/coding-agent/package.json`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/package.json), `engines.node` and production dependencies.
[^pi-bash]: Pi 0.84.2, [`src/core/tools/bash.ts:83-106`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/bash.ts#L83-L106).
[^pi-tools]: Pi 0.84.2, [`src/utils/tools-manager.ts:15-116, 231-319`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/utils/tools-manager.ts) and [`grep.ts:174-180`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/grep.ts#L174-L180), [`find.ts:222-232`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/find.ts#L222-L232).
[^pi-readme]: Pi 0.84.2, [`README.md`, Philosophy and MCP](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#philosophy).
[^mcp-package]: pi-mcp-adapter 2.27.0, [`package.json`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/package.json), including `pi.extensions`, dependencies, peer dependencies, and Node engine.
[^mcp-auth]: pi-mcp-adapter 2.27.0, [Remote/headless OAuth](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#remoteheadless-oauth) and [`mcp-auth.ts:1-18, 274-391`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/mcp-auth.ts).
[^pi-offline]: Pi 0.84.2, [`README.md`, Telemetry and update checks](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#telemetry-and-update-checks).
[^pi-rpc-refresh]: Pi 0.84.2, [`src/main.ts:926-940`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/main.ts#L926-L940).
[^pi-paths]: Pi 0.84.2, [`src/config.ts:494-565`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/config.ts#L494-L565).
[^pi-providers]: Pi 0.84.2, [Providers](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/providers.md), including credential resolution order, auth file writes, cloud credentials, and custom providers.
[^pi-auth-storage]: Pi 0.84.2, [`src/core/auth-storage.ts:47-188`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/auth-storage.ts#L47-L188).
[^pi-model-store]: Pi 0.84.2, [`src/core/model-runtime.ts:171-212`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/model-runtime.ts#L171-L212) and [`models-store.ts:49-53`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/models-store.ts#L49-L53).
[^pi-trust]: Pi 0.84.2, [Settings, Project Trust](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/settings.md#project-trust).
[^mcp-cache]: pi-mcp-adapter 2.27.0, [`metadata-cache.ts:39-79`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/metadata-cache.ts#L39-L79) and [Direct Tools](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#direct-tools).
[^pi-resources]: Pi 0.84.2, [Settings, Resources](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/settings.md#resources) and [Extensions, Security](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/extensions.md#extension-locations).
[^pi-packages]: Pi 0.84.2, [Pi Packages, package sources and dependencies](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/packages.md).
[^pi-sessions]: Pi 0.84.2, [`README.md`, Sessions](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#sessions) and [`session-manager.ts:474-487`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/session-manager.ts#L474-L487).
[^paseo-resume]: [`packages/server/src/server/agent/providers/pi/agent.ts:467-527, 2462-2523`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-import]: [`packages/server/src/server/agent/providers/pi/session-descriptor.ts:72-145`](../../packages/server/src/server/agent/providers/pi/session-descriptor.ts).
[^mcp-output]: pi-mcp-adapter 2.27.0, [Output Guard](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#output-guard) and [`mcp-output-guard.ts:358-363`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/mcp-output-guard.ts#L358-L363).
[^paseo-generated]: [`packages/server/src/server/agent/providers/pi/agent.ts:564-736`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-detect]: [`packages/server/src/server/agent/providers/pi/agent.ts:750-758, 2623-2653`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-mcp]: [`packages/server/src/server/agent/providers/pi/agent.ts:239-252, 512-527`](../../packages/server/src/server/agent/providers/pi/agent.ts) and [`runtime-mcp-config.ts:24-59`](../../packages/server/src/server/agent/runtime-mcp-config.ts).
[^mcp-config]: pi-mcp-adapter 2.27.0, [Config and precedence](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#config) and [`config.ts:394-451`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/config.ts#L394-L451).
[^paseo-mcp-address]: [`packages/server/src/server/bootstrap.ts:243-260`](../../packages/server/src/server/bootstrap.ts).
[^paseo-mcp-auth]: [`packages/server/src/server/bootstrap.ts:631-638`](../../packages/server/src/server/bootstrap.ts), [`auth.ts:135-181`](../../packages/server/src/server/auth.ts), and [`runtime-mcp-config.ts:24-59`](../../packages/server/src/server/agent/runtime-mcp-config.ts).
[^paseo-docker]: [`docs/docker.md`](../../docs/docker.md) and [`docker/base/Dockerfile`](../../docker/base/Dockerfile).
+1 -1
View File
@@ -1 +1 @@
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
sha256-+fewwjlLlJN9VXiBOGvALNQgNxnoVXYQzBDra3ylJhA=
+2 -2
View File
@@ -38881,7 +38881,7 @@
"version": "0.5.0-beta.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4",
@@ -38890,7 +38890,7 @@
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
+21 -3
View File
@@ -996,6 +996,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1095,7 +1112,7 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
@@ -1105,7 +1122,7 @@ test("the first click after a canvas selection only dismisses the selection", as
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
@@ -1537,7 +1554,8 @@ async function startReviewOnFirstChangedLine(
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.getByRole("button", { name: "Add review comment" }).click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
}
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function sidePanel(page: Page) {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel(page)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,113 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function visibleMainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
await expect
.poll(async () => {
const workspaces = await workspace.client.fetchWorkspaces();
return (
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
);
})
.toEqual({ additions: 2, deletions: 0 });
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const pill = page.getByTestId("composer-diff-stat-pill");
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("+2");
await expect(pill).toContainText("-0");
await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await test.step("switching from another Side panel view reveals Changes", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
const mainPane = visibleMainPane(page);
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,79 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visibleEmptyPane(page: Page): Locator {
return page.getByTestId("workspace-pane-empty-state").filter({ visible: true });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-pane-empty-state");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
}
test.describe("Empty pane keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("a new split pane focuses its launcher and wraps arrow navigation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleEmptyPane(page);
await expect(launcher).toBeVisible({ timeout: 30_000 });
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowUp");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-lockfile-",
title: "Large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "package-lock.json");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText('"package-0"');
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
} finally {
await session.cleanup();
}
});
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-plain-",
title: "Plain large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "plain.txt"),
"plain source\n".repeat(1_050_000),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "plain.txt");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("plain source");
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refuses a file above the display budget and keeps its tab recoverable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-unsupported-",
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "too-large.txt");
await expect(page.getByTestId("file-source-too-large")).toContainText(
"This file is too large to display",
);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate(() => {
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
(
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
).__fileTransitionOriginalChip = originalChip;
});
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const chipIdentity = await page.evaluate((tabTestId) => {
const windowWithOriginal = window as typeof window & {
__fileTransitionOriginalChip?: Element | null;
};
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
return {
originalStillConnected: originalChip?.isConnected ?? false,
sameNode: originalChip === currentChip,
};
}, `workspace-tab-file_${targetPath}`);
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
const heldTimeline = await page.evaluate(
() =>
(
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
let workspace: SeededWorkspace;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
return locator.evaluate((element) => {
let current: Element | null = element;
while (current) {
const color = getComputedStyle(current).backgroundColor;
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
current = current.parentElement;
}
return "transparent";
});
}
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-",
@@ -21,6 +33,21 @@ test.afterAll(async () => {
});
test.describe("File panel tree rail", () => {
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
await expect(rail).toBeVisible({ timeout: 30_000 });
const content = rail.getByTestId("files-tree-rail-content");
const tree = rail.getByTestId("files-tree-rail-tree");
await expect(content).toHaveText("Choose a file");
await expect(tree).toContainText("docs");
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
await effectiveBackground(content),
);
});
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
@@ -156,7 +156,7 @@ test.describe("Tab creation", () => {
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused();
+15 -1
View File
@@ -22,6 +22,12 @@ function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
return launcher
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
@@ -61,7 +67,15 @@ test.describe("Side panel", () => {
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
const launcher = emptyState(sidePanel(page));
await expect(launcher).toBeVisible();
expect(await launcherButtonLabels(launcher)).toEqual([
"Changes",
"Files",
"Agent",
"Terminal",
null,
]);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
}
}
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
await expect(notesHint).toHaveText(
"Helps agents choose this profile when starting another agent.",
);
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
"1",
);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
value,
);
continue;
}
await expect(
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number;
initialPrompt?: string;
model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
const workspace = await seedWorkspace({
repoPrefix: options.repoPrefix,
repo: options.repo,
port: options.port,
});
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[];
}
@@ -1,7 +1,6 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
@@ -27,6 +26,7 @@ import {
Server,
Shield,
Sparkles,
Star,
Terminal,
TestTube,
Wrench,
@@ -42,7 +42,7 @@ import {
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
const ThemedDefaultIcon = withUnistyles(Star);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
openPanel: (name, placement) => `Open ${name} ${placement}`,
previousTab: "Previous tab",
nextTab: "Next tab",
closeCurrentTab: "Close current tab",
renameTab: "Rename",
reloadAgent: "Reload agent",
copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id",
copyTerminalId: "Copy terminal id",
copyFilePath: "Copy file path",
closeTabsLeft: "Close tabs left",
closeTabsRight: "Close tabs right",
closeOtherTabs: "Close other tabs",
focusPaneLeft: "Focus pane left",
focusPaneRight: "Focus pane right",
focusPaneUp: "Focus pane up",
focusPaneDown: "Focus pane down",
moveTabLeft: "Move tab left",
moveTabRight: "Move tab right",
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
expect(
contributions
.filter((item) =>
[
"tab:new-agent",
"git:commit",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
].includes(item.id),
)
.map(({ id, visibility }) => ({ id, visibility })),
).toEqual([
{ id: "tab:new-agent", visibility: "always" },
{ id: "git:commit", visibility: "always" },
{ id: "tab:new-terminal", visibility: "query" },
{ id: "tab:new-browser", visibility: "query" },
{ id: "pane:split-right", visibility: "query" },
{ id: "pane:split-down", visibility: "query" },
]);
});
it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
fixture.value.capabilities = {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.agent.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string;
splitRight: string;
splitDown: string;
changes: string;
files: string;
pullRequest: string;
openPanel(name: string, placement: WorkspacePanelPlacement): string;
previousTab: string;
nextTab: string;
closeCurrentTab: string;
renameTab: string;
reloadAgent: string;
copyResumeCommand: string;
copyAgentId: string;
copyTerminalId: string;
copyFilePath: string;
closeTabsLeft: string;
closeTabsRight: string;
closeOtherTabs: string;
focusPaneLeft: string;
focusPaneRight: string;
focusPaneUp: string;
focusPaneDown: string;
moveTabLeft: string;
moveTabRight: string;
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon;
changes?: CommandCenterIcon;
files?: CommandCenterIcon;
pullRequest?: CommandCenterIcon;
previousTab?: CommandCenterIcon;
nextTab?: CommandCenterIcon;
close?: CommandCenterIcon;
rename?: CommandCenterIcon;
reload?: CommandCenterIcon;
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
}
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
};
}
export function buildWorkspaceCommandCenterContributions(
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
function buildQueryAction(
source: WorkspaceCommandCenterSource,
input: QueryActionInput,
): CommandCenterContribution {
return buildWorkspaceAction({ ...input, source, visibility: "query" });
}
function buildPanelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
const panelTargets: Array<{
target: WorkspacePanelTarget;
name: string;
icon?: CommandCenterIcon;
}> = [
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
{ target: "files", name: source.labels.files, icon: source.icons.files },
{
target: "pull-request",
name: source.labels.pullRequest,
icon: source.icons.pullRequest,
},
];
for (const [index, panel] of panelTargets.entries()) {
if (panel.target !== "files" && !source.capabilities.isGit) continue;
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}`,
rank: 4 + index,
title: source.labels.openPanel(panel.name, "supporting"),
keywords: ["open", panel.target, "tab", "supporting"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement: "supporting",
},
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
rank: 10 + index * 2 + placementIndex,
title: source.labels.openPanel(panel.name, placement),
keywords: ["open", panel.target, "tab", placement, "pane"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement,
},
}),
);
}
}
return contributions;
}
function buildActiveTabContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (source.activeTabCount === 0) return [];
const contributions: CommandCenterContribution[] = [
buildQueryAction(source, {
id: "tab:previous",
rank: 30,
title: source.labels.previousTab,
keywords: ["tab", "previous", "switch", "navigate"],
icon: source.icons.previousTab,
shortcutKeys: source.shortcuts.previousTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
}),
buildQueryAction(source, {
id: "tab:next",
rank: 31,
title: source.labels.nextTab,
keywords: ["tab", "next", "switch", "navigate"],
icon: source.icons.nextTab,
shortcutKeys: source.shortcuts.nextTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
}),
buildQueryAction(source, {
id: "tab:close-current",
rank: 32,
title: source.labels.closeCurrentTab,
keywords: ["tab", "close", "current"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closeCurrentTab,
action: { id: "workspace.tab.close-current", scope: "workspace" },
}),
];
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
contributions.push(
buildQueryAction(source, {
id: "tab:rename-current",
rank: 33,
title: source.labels.renameTab,
keywords: ["tab", "rename", "current"],
icon: source.icons.rename,
action: { id: "workspace.tab.rename-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-id",
rank: 34,
title:
source.activeTabKind === "agent"
? source.labels.copyAgentId
: source.labels.copyTerminalId,
keywords: ["tab", "copy", "id"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-id", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "agent") {
contributions.push(
buildQueryAction(source, {
id: "tab:reload-current",
rank: 35,
title: source.labels.reloadAgent,
keywords: ["tab", "agent", "reload"],
icon: source.icons.reload,
action: { id: "workspace.tab.reload-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-resume-command",
rank: 36,
title: source.labels.copyResumeCommand,
keywords: ["tab", "agent", "copy", "resume", "command"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "file") {
contributions.push(
buildQueryAction(source, {
id: "tab:copy-file-path",
rank: 37,
title: source.labels.copyFilePath,
keywords: ["tab", "file", "copy", "path"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
}),
);
}
if (source.activeTabIndex > 0) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-left",
rank: 38,
title: source.labels.closeTabsLeft,
keywords: ["tab", "close", "left"],
icon: source.icons.close,
action: { id: "workspace.tab.close-left", scope: "workspace" },
}),
);
}
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-right",
rank: 39,
title: source.labels.closeTabsRight,
keywords: ["tab", "close", "right"],
icon: source.icons.close,
action: { id: "workspace.tab.close-right", scope: "workspace" },
}),
);
}
if (source.activeTabCount > 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-others",
rank: 40,
title: source.labels.closeOtherTabs,
keywords: ["tab", "close", "others"],
icon: source.icons.close,
action: { id: "workspace.tab.close-others", scope: "workspace" },
}),
);
}
return contributions;
}
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
const paneActions: Array<{
id: string;
rank: number;
title: string;
keywords: string[];
action: KeyboardActionDefinition;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
}> = [
{
id: "pane:split-right",
rank: 50,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
},
{
id: "pane:split-down",
rank: 51,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
},
{
id: "pane:focus-left",
rank: 52,
title: source.labels.focusPaneLeft,
keywords: ["pane", "focus", "left"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.left", scope: "workspace" },
},
{
id: "pane:focus-right",
rank: 53,
title: source.labels.focusPaneRight,
keywords: ["pane", "focus", "right"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.right", scope: "workspace" },
},
{
id: "pane:focus-up",
rank: 54,
title: source.labels.focusPaneUp,
keywords: ["pane", "focus", "up"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.up", scope: "workspace" },
},
{
id: "pane:focus-down",
rank: 55,
title: source.labels.focusPaneDown,
keywords: ["pane", "focus", "down"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.down", scope: "workspace" },
},
{
id: "pane:move-tab-left",
rank: 56,
title: source.labels.moveTabLeft,
keywords: ["pane", "tab", "move", "left"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
},
{
id: "pane:move-tab-right",
rank: 57,
title: source.labels.moveTabRight,
keywords: ["pane", "tab", "move", "right"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
},
{
id: "pane:move-tab-up",
rank: 58,
title: source.labels.moveTabUp,
keywords: ["pane", "tab", "move", "up"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
},
{
id: "pane:move-tab-down",
rank: 59,
title: source.labels.moveTabDown,
keywords: ["pane", "tab", "move", "down"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
},
{
id: "pane:close",
rank: 60,
title: source.labels.closePane,
keywords: ["pane", "close"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
title: source.labels.toggleFocusMode,
keywords: ["pane", "focus", "mode", "toggle"],
icon: source.icons.focusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
},
];
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
return contributions;
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions = [
...buildCreationContributions(source),
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "side-panel:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
);
}
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
return contributions;
}
@@ -1,6 +1,26 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
import {
ArrowDownToLine,
ArrowLeft,
ArrowRight,
Columns2,
Copy,
Files,
Focus,
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
@@ -13,6 +33,12 @@ import {
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2),
changes: getCommandCenterIcon(GitCompareArrows),
files: getCommandCenterIcon(Files),
pullRequest: getCommandCenterIcon(GitPullRequest),
previousTab: getCommandCenterIcon(ArrowLeft),
nextTab: getCommandCenterIcon(ArrowRight),
close: getCommandCenterIcon(X),
rename: getCommandCenterIcon(Pencil),
reload: getCommandCenterIcon(RotateCw),
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined;
function StaticIcon() {
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
previousTab:
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
closeCurrentTab:
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -60,10 +117,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const workspaceKey =
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
const focusedTabs = layout
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
: [];
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"),
changes: t("workspace.tabs.actions.changes"),
files: t("workspace.tabs.actions.files"),
pullRequest: t("workspace.tabs.actions.pullRequest"),
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
previousTab: t("settings.shortcuts.help.previousTab"),
nextTab: t("settings.shortcuts.help.nextTab"),
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
renameTab: t("workspace.tabs.menu.rename"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
closeTabsRight: t("workspace.tabs.menu.closeRight"),
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
[
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
);
useCommandCenterActions({
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
@@ -135,6 +135,7 @@ interface SplitContainerProps {
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
@@ -1289,6 +1290,7 @@ function SplitPaneView({
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
isFocused: isFocused && isWorkspaceFocused,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
@@ -86,23 +86,28 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.lineHeight).toBe(20);
});
it("derives field padding from line height without changing the control height", () => {
it("derives field padding from content and border without changing the control height", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
geometry.fieldControlSm.minHeight,
);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
geometry.fieldControlMd.minHeight,
);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
@@ -78,8 +78,12 @@ function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
return (controlHeight - lineHeight) / 2;
function fieldVerticalPadding(
controlHeight: number,
lineHeight: number,
borderWidth: number,
): number {
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
export function getControlInteractionPhase(
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
}
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextSm = {
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: theme.borderWidth[1],
borderWidth: controlBorderWidth,
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
}
if (hint) {
return (
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
<Text style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useWorkspaceHasDiffStat } from "./workspace-diff-stat";
const SERVER_ID = "diff-stat-pill";
const WORKSPACE_ID = "workspace";
const workspace: WorkspaceDescriptor = {
id: WORKSPACE_ID,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
function setDiffStat(diffStat: WorkspaceDescriptor["diffStat"]): void {
useSessionStore.getState().setWorkspaces(SERVER_ID, (workspaces) => {
const next = new Map(workspaces);
next.set(WORKSPACE_ID, { ...workspace, diffStat });
return next;
});
}
describe("useWorkspaceHasDiffStat", () => {
beforeEach(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
seedSessionWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace]]));
});
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
it("does not re-render its owner when only the visible counts change", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useWorkspaceHasDiffStat(SERVER_ID, WORKSPACE_ID);
});
expect(result.current).toBe(false);
expect(renderCount).toBe(1);
act(() => setDiffStat({ additions: 2, deletions: 0 }));
expect(result.current).toBe(true);
expect(renderCount).toBe(2);
act(() => setDiffStat({ additions: 5, deletions: 3 }));
expect(result.current).toBe(true);
expect(renderCount).toBe(2);
act(() => setDiffStat(null));
expect(result.current).toBe(false);
expect(renderCount).toBe(3);
});
});
@@ -0,0 +1,59 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import { DiffStat } from "@/components/diff-stat";
import { composerPillStyles } from "@/composer/pill-styles";
import { useVisibleWorkspaceDiffStat } from "@/composer/workspace-diff-stat";
interface ComposerDiffStatPillProps {
additions: number;
deletions: number;
onPress: () => void;
}
export function ComposerDiffStatPill({ additions, deletions, onPress }: ComposerDiffStatPillProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(
() => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
[isHovered],
);
return (
<Pressable
testID="composer-diff-stat-pill"
accessibilityRole="button"
accessibilityLabel={t("workspace.git.diff.openChangesTab")}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={bodyStyle}
>
<DiffStat additions={additions} deletions={deletions} />
</Pressable>
);
}
export const WorkspaceDiffStatPill = memo(function WorkspaceDiffStatPill({
serverId,
workspaceId,
onPress,
}: {
serverId: string;
workspaceId: string;
onPress: () => void;
}): ReactElement | null {
const diffStat = useVisibleWorkspaceDiffStat(serverId, workspaceId);
if (!diffStat) {
return null;
}
return (
<ComposerDiffStatPill
additions={diffStat.additions}
deletions={diffStat.deletions}
onPress={onPress}
/>
);
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
describe("composer input height style", () => {
it("applies the measured height alongside its bounds", () => {
expect(
resolveComposerInputHeightStyle({
inputHeight: 96,
minInputHeight: 30,
maxInputHeight: 240,
applyMeasuredHeight: true,
}),
).toEqual({
height: 96,
minHeight: 30,
maxHeight: 240,
});
});
it("lets a native multiline input expose its intrinsic content height", () => {
expect(
resolveComposerInputHeightStyle({
inputHeight: 96,
minInputHeight: 30,
maxInputHeight: 240,
applyMeasuredHeight: false,
}),
).toEqual({
minHeight: 30,
maxHeight: 240,
});
});
it("keeps native measurement unconstrained until the composer reaches its cap", () => {
expect(shouldScrollComposerInput({ inputHeight: 30, maxInputHeight: 240 })).toBe(false);
expect(shouldScrollComposerInput({ inputHeight: 240, maxInputHeight: 240 })).toBe(true);
});
});
@@ -0,0 +1,24 @@
interface ComposerInputHeightStyleInput {
inputHeight: number;
minInputHeight: number;
maxInputHeight: number;
applyMeasuredHeight: boolean;
}
export function resolveComposerInputHeightStyle(input: ComposerInputHeightStyleInput) {
// Native must remain intrinsically sized below the cap so
// onContentSizeChange can observe growth. Web gets its height from the DOM
// mirror and therefore needs that measurement applied explicitly.
return {
...(input.applyMeasuredHeight ? { height: input.inputHeight } : {}),
minHeight: input.minInputHeight,
maxHeight: input.maxInputHeight,
};
}
export function shouldScrollComposerInput(input: {
inputHeight: number;
maxInputHeight: number;
}): boolean {
return input.inputHeight >= input.maxInputHeight;
}
+8 -16
View File
@@ -56,6 +56,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
@@ -990,20 +991,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
if (isWeb) {
return {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
};
}
return {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
};
}
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
@@ -1764,7 +1751,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
computeTextInputHeightStyle(inputHeight, maxInputHeight),
resolveComposerInputHeightStyle({
inputHeight,
minInputHeight: MIN_INPUT_HEIGHT,
maxInputHeight,
applyMeasuredHeight: isWeb,
}),
],
[inputHeight, maxInputHeight, mode.isMonospace],
);
@@ -1838,7 +1830,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })}
autoFocus={false}
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
@@ -0,0 +1,24 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
export function useVisibleWorkspaceDiffStat(
serverId: string,
workspaceId: string,
): DiffStat | null {
const diffStat = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.diffStat);
return hasDiffStatChanges(diffStat) ? diffStat : null;
}
export function useWorkspaceHasDiffStat(serverId: string, workspaceId: string): boolean {
return (
useWorkspaceFields(serverId, workspaceId, (workspace) =>
hasDiffStatChanges(workspace.diffStat),
) ?? false
);
}
function hasDiffStatChanges(diffStat: WorkspaceDescriptor["diffStat"]): diffStat is DiffStat {
return Boolean(diffStat && (diffStat.additions > 0 || diffStat.deletions > 0));
}
+8 -1
View File
@@ -1,6 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(target.cwd, target.path);
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
+60 -179
View File
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
mode?: "preview" | "source";
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<View style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
))}
</Text>
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
});
interface CodeLineTokenProps {
token: HighlightToken;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
},
}));
function FilePreviewBody({
preview,
mode,
isLoading,
isMobile,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
@@ -197,48 +146,11 @@ function FilePreviewBody({
const previewScrollRef = useRef<RNScrollView>(null);
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text" || renderKind) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [renderKind, preview, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection, navigationRevision]);
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
@@ -280,51 +192,13 @@ function FilePreviewBody({
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
</View>
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
@@ -557,6 +431,13 @@ function FilePanePresentation({
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
describe("selectSourcePresentation", () => {
for (const platform of ["web", "native"] as const) {
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
it(`keeps ${platform} threshold edges predictable`, () => {
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
});
}
});
@@ -0,0 +1,17 @@
export const SOURCE_PRESENTATION_BUDGETS = {
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
} as const;
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
export function selectSourcePresentation(input: {
size: number;
platform: SourcePlatform;
}): SourcePresentation {
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
if (input.size <= budgets.highlighted) return "highlighted";
if (input.size <= budgets.plain) return "plain";
return "unsupported";
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -0,0 +1 @@
export { FileSourceView } from "./view.native";
@@ -0,0 +1,131 @@
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
theme,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "web" });
if (presentation === "unsupported") {
return (
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
{tooLargeMessage}
</div>
);
}
return (
<ReadonlyCodeMirror
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
theme={theme}
/>
);
}
function ReadonlyCodeMirror({
content,
filename,
location,
navigationRevision,
presentation,
theme,
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
),
themeCompartment.of(editorTheme(values.theme)),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === content) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
}, [content]);
useEffect(() => {
viewRef.current?.dispatch({
effects: [
languageCompartment.reconfigure(languageFor({ filename, presentation })),
themeCompartment.reconfigure(editorTheme(theme)),
],
});
}, [filename, presentation, theme]);
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const line = Math.min(location.lineStart, view.state.doc.lines);
const from = view.state.doc.line(line).from;
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
filename: string;
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
return input.presentation === "highlighted"
? (getLanguageForFile(input.filename)?.extension ?? [])
: [];
}
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
display: "flex",
flex: 1,
justifyContent: "center",
} as const;
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
schedulePaint();
return;
}
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
if (
event.pointerType === "touch" &&
drag &&
!moved &&
hit?.kind === "cell" &&
hit.target &&
reviewActions
) {
selectionRef.current = null;
reviewActions.onStartComment(hit.target);
schedulePaint();
+7 -2
View File
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
openInFocusedPane: "فتح {{name}} في الجزء النشط",
addProject: "إضافة مشروع",
home: "بيت",
groupByProject: "تجميع حسب المشروع",
@@ -1700,6 +1703,7 @@ export const ar: TranslationResources = {
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
@@ -1728,6 +1732,7 @@ export const ar: TranslationResources = {
label: "الملفات",
subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل",
chooseFile: "اختر ملفًا",
},
pullRequest: {
label: "طلب السحب",
@@ -2342,9 +2347,9 @@ export const ar: TranslationResources = {
featuresLabel: "السمات",
featureCount: "{{count}} سمة",
featureCountOne: "{{count}} سمة",
notesLabel: "ملاحظات للوكلاء",
notesLabel: "متى يُستخدم",
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.',
notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
save: "حفظ",
saving: "جارٍ الحفظ...",
remove: "إزالة",
+7 -3
View File
@@ -61,6 +61,9 @@ export const en = {
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
open: "Open {{name}}",
openInSidePanel: "Open {{name}} in Side panel",
openInFocusedPane: "Open {{name}} in focused pane",
addProject: "Add project",
home: "Home",
groupByProject: "Group by project",
@@ -1710,6 +1713,7 @@ export const en = {
loading: "Loading file...",
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
@@ -1738,6 +1742,7 @@ export const en = {
label: "Files",
subtitle: "Workspace files",
tooltip: "Browse workspace files",
chooseFile: "Choose a file",
},
pullRequest: {
label: "Pull request",
@@ -2409,10 +2414,9 @@ export const en = {
featuresLabel: "Features",
featureCount: "{{count}} features",
featureCountOne: "{{count}} feature",
notesLabel: "Notes for agents",
notesLabel: "When to use",
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
notesHint:
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
notesHint: "Helps agents choose this profile when starting another agent.",
save: "Save",
saving: "Saving...",
remove: "Remove",
+7 -3
View File
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} en el panel lateral",
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
addProject: "Agregar proyecto",
home: "Hogar",
groupByProject: "Agrupar por proyecto",
@@ -1746,6 +1749,7 @@ export const es: TranslationResources = {
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
@@ -1774,6 +1778,7 @@ export const es: TranslationResources = {
label: "Archivos",
subtitle: "Archivos del espacio de trabajo",
tooltip: "Explorar archivos del espacio de trabajo",
chooseFile: "Elige un archivo",
},
pullRequest: {
label: "Solicitud de extracción",
@@ -2396,10 +2401,9 @@ export const es: TranslationResources = {
featuresLabel: "Características",
featureCount: "{{count}} características",
featureCountOne: "{{count}} característica",
notesLabel: "Notas para los agentes",
notesLabel: "Cuándo usarlo",
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
notesHint:
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
save: "Guardar",
saving: "Guardando...",
remove: "Eliminar",
+7 -3
View File
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail",
agents: "Agents",
newAgent: "Nouvel agent",
open: "Ouvrir {{name}}",
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
addProject: "Ajouter un projet",
home: "Maison",
groupByProject: "Grouper par projet",
@@ -1750,6 +1753,7 @@ export const fr: TranslationResources = {
loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
@@ -1778,6 +1782,7 @@ export const fr: TranslationResources = {
label: "Fichiers",
subtitle: "Fichiers de l’espace de travail",
tooltip: "Parcourir les fichiers de l’espace de travail",
chooseFile: "Choisissez un fichier",
},
pullRequest: {
label: "Demande de fusion",
@@ -2403,11 +2408,10 @@ export const fr: TranslationResources = {
featuresLabel: "Caractéristiques",
featureCount: "{{count}} caractéristiques",
featureCountOne: "{{count}} caractéristique",
notesLabel: "Notes pour les agents",
notesLabel: "Quand l’utiliser",
notesPlaceholder:
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
notesHint:
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
save: "Enregistrer",
saving: "Enregistrement...",
remove: "Supprimer",
+7 -3
View File
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
open: "{{name}}を開く",
openInSidePanel: "{{name}}をサイドパネルで開く",
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
addProject: "プロジェクトを追加",
home: "ホーム",
groupByProject: "プロジェクトでグループ化",
@@ -1717,6 +1720,7 @@ export const ja: TranslationResources = {
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
@@ -1745,6 +1749,7 @@ export const ja: TranslationResources = {
label: "ファイル",
subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照",
chooseFile: "ファイルを選択",
},
pullRequest: {
label: "プルリクエスト",
@@ -2365,10 +2370,9 @@ export const ja: TranslationResources = {
featuresLabel: "機能",
featureCount: "{{count}}個の機能",
featureCountOne: "{{count}}個の機能",
notesLabel: "エージェントへのメモ",
notesLabel: "使用する場面",
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
notesHint:
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
save: "保存",
saving: "保存中...",
remove: "削除",
+7 -3
View File
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
workspaces: "워크스페이스",
agents: "에이전트",
newAgent: "새 에이전트",
open: "{{name}} 열기",
openInSidePanel: "사이드 패널에서 {{name}} 열기",
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
addProject: "프로젝트 추가",
home: "홈",
groupByProject: "프로젝트별 그룹화",
@@ -1710,6 +1713,7 @@ export const ko: TranslationResources = {
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
@@ -1738,6 +1742,7 @@ export const ko: TranslationResources = {
label: "파일",
subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색",
chooseFile: "파일 선택",
},
pullRequest: {
label: "풀 리퀘스트",
@@ -2354,10 +2359,9 @@ export const ko: TranslationResources = {
featuresLabel: "기능",
featureCount: "기능 {{count}}개",
featureCountOne: "기능 {{count}}개",
notesLabel: "에이전트를 위한 메모",
notesLabel: "사용 시점",
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
notesHint:
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
save: "저장",
saving: "저장하는 중...",
remove: "제거",
+7 -3
View File
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} no painel lateral",
openInFocusedPane: "Abrir {{name}} no painel em foco",
addProject: "Adicionar projeto",
home: "Início",
groupByProject: "Agrupar por projeto",
@@ -1732,6 +1735,7 @@ export const ptBR: TranslationResources = {
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
@@ -1760,6 +1764,7 @@ export const ptBR: TranslationResources = {
label: "Arquivos",
subtitle: "Arquivos do espaço de trabalho",
tooltip: "Explorar arquivos do espaço de trabalho",
chooseFile: "Escolha um arquivo",
},
pullRequest: {
label: "Pull request",
@@ -2381,10 +2386,9 @@ export const ptBR: TranslationResources = {
featuresLabel: "Recursos",
featureCount: "{{count}} recursos",
featureCountOne: "{{count}} recurso",
notesLabel: "Notas para os agentes",
notesLabel: "Quando usar",
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
notesHint:
'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.',
notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.",
save: "Salvar",
saving: "Salvando...",
remove: "Remover",
+7 -3
View File
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
open: "Открыть {{name}}",
openInSidePanel: "Открыть {{name}} в боковой панели",
openInFocusedPane: "Открыть {{name}} в активной области",
addProject: "Добавить проект",
home: "Главная",
groupByProject: "Группировать по проекту",
@@ -1730,6 +1733,7 @@ export const ru: TranslationResources = {
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
@@ -1759,6 +1763,7 @@ export const ru: TranslationResources = {
label: "Файлы",
subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства",
chooseFile: "Выберите файл",
},
pullRequest: {
label: "PR",
@@ -2386,10 +2391,9 @@ export const ru: TranslationResources = {
featuresLabel: "Функции",
featureCount: "Функции: {{count}}",
featureCountOne: "{{count}} функция",
notesLabel: "Заметки для агентов",
notesLabel: "Когда использовать",
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
notesHint:
"MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.",
notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.",
save: "Сохранить",
saving: "Сохранение...",
remove: "Удалить",
+7 -2
View File
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
open: "打开{{name}}",
openInSidePanel: "在侧边面板中打开{{name}}",
openInFocusedPane: "在当前窗格中打开{{name}}",
addProject: "添加 project",
home: "首页",
groupByProject: "按项目分组",
@@ -1680,6 +1683,7 @@ export const zhCN: TranslationResources = {
loading: "正在加载文件...",
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
@@ -1708,6 +1712,7 @@ export const zhCN: TranslationResources = {
label: "文件",
subtitle: "工作区文件",
tooltip: "浏览工作区文件",
chooseFile: "选择文件",
},
pullRequest: {
label: "拉取请求",
@@ -2316,9 +2321,9 @@ export const zhCN: TranslationResources = {
featuresLabel: "Features",
featureCount: "{{count}} 个 Feature",
featureCountOne: "{{count}} 个 Feature",
notesLabel: "给 Agent 的说明",
notesLabel: "适用场景",
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。",
notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。",
save: "保存",
saving: "保存中...",
remove: "移除",
@@ -1,5 +1,8 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type WorkspacePanelTarget = "changes" | "files" | "pull-request";
export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane";
export type KeyboardActionId =
| "agent.interrupt"
| "message-input.focus"
@@ -19,6 +22,15 @@ export type KeyboardActionId =
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative"
| "workspace.tab.open"
| "workspace.tab.rename-current"
| "workspace.tab.reload-current"
| "workspace.tab.copy-resume-command"
| "workspace.tab.copy-id"
| "workspace.tab.copy-file-path"
| "workspace.tab.close-left"
| "workspace.tab.close-right"
| "workspace.tab.close-others"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| {
id: "workspace.tab.open";
scope: KeyboardActionScope;
target: WorkspacePanelTarget;
placement: WorkspacePanelPlacement;
}
| { id: "workspace.tab.rename-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.reload-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-id"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-left"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-right"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-others"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
@@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => {
const targetCases = [
["a", "KeyA", "workspace.tab.target.agent"],
["b", "KeyB", "workspace.tab.target.browser"],
["c", "KeyC", "workspace.tab.target.changes"],
["g", "KeyG", "workspace.tab.target.changes"],
["e", "KeyE", "workspace.tab.target.files"],
] as const;
@@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => {
it("uses the existing override map for target matching and display", () => {
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac";
const overrides = { [bindingId]: "Ctrl+Shift+G" };
const overrides = { [bindingId]: "Ctrl+Shift+H" };
const rebound = resolveShortcut({
event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true },
event: { key: "h", code: "KeyH", ctrlKey: true, shiftKey: true },
context: { ...desktopNonMac, focusScope: "other" },
bindings: buildEffectiveBindings(overrides),
});
@@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => {
expect(original.match).toBeNull();
expect(
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
).toEqual([["ctrl", "shift", "G"]]);
).toEqual([["ctrl", "shift", "H"]]);
});
});
@@ -454,9 +454,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
// Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive.
id: "workspace-tab-target-changes-cmd-shift-c-mac",
action: "workspace.tab.target.changes",
combo: "Cmd+Shift+C",
combo: "Cmd+Shift+G",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-tab-target-changes",
@@ -465,9 +466,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
// Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive.
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
action: "workspace.tab.target.changes",
combo: "Ctrl+Shift+C",
combo: "Ctrl+Shift+G",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-target-changes",
+26 -9
View File
@@ -25,6 +25,7 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { Composer } from "@/composer";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
@@ -748,6 +749,7 @@ function AgentPanelBody({
return (
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
@@ -760,6 +762,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
client,
@@ -768,6 +771,7 @@ function ChatAgentContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
@@ -1164,6 +1168,7 @@ function ChatAgentContent({
return (
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
@@ -1192,6 +1197,7 @@ function ChatAgentContent({
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
isArchivingCurrentAgent,
@@ -1216,6 +1222,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
@@ -1240,8 +1247,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1252,9 +1257,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
rows: subagentRows,
});
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const showAgentTracks =
hasActiveComposer &&
hasAgentTracks({
const hasVisibleAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
@@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{showAgentTracks ? (
{hasActiveComposer ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
@@ -1409,24 +1415,35 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
workspaceId,
agentId,
agent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
bottomOverlayTailClearance,
hasActiveComposer,
hasVisibleAgentTracks,
toast,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
workspaceId: string;
agentId?: string;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
bottomOverlayTailClearance: number;
hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const isCompactFormFactor = useIsCompactFormFactor();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const hasVisibleComposerTracks =
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
const bottomOverlayTailClearance = hasVisibleComposerTracks
? resolveComposerTrackTailClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
+28 -7
View File
@@ -1,4 +1,6 @@
import { memo, useCallback, type ReactElement } from "react";
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
@@ -15,29 +17,32 @@ import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel";
/**
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
* the composer.
*
* The row shares the composer's keyboard transform and owns the space between itself and the
* transcript. Its data remains agent state: a subagent row opens a tab and the task list reads
* the agent's stream.
* transcript. Each pill owns its action while tab placement stays behind the workspace boundary.
*/
export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
}: {
serverId: string;
workspaceId: string;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
}): ReactElement | null {
const { workspaceId, tabId, openTab } = usePaneContext();
const { tabId, openTab } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePanelByDefault = useSettings(
@@ -87,13 +92,25 @@ export const AgentTracks = memo(function AgentTracks({
},
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
}
toggleSupportingTab({
isCompact,
workspaceKey,
target: { kind: "working_diff" },
openInSidePanelByDefault,
});
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
return null;
}
return (
<ComposerTrackBar>
<AgentTaskList tasks={tasks} />
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -103,7 +120,11 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<AgentTaskList tasks={tasks} />
<WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
onPress={handleOpenChanges}
/>
</ComposerTrackBar>
);
});
+35 -8
View File
@@ -1,6 +1,6 @@
import { Text, View } from "react-native";
import { FolderTree } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
@@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { TreeRail } from "@/components/tree-rail";
import { useIsCompactFormFactor } from "@/constants/layout";
const ThemedFolderTree = withUnistyles(FolderTree);
const CENTERED_PADDED_STYLE = {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 16,
} as const;
const FILE_TREE_WIDTH = 220;
function useFilesPanelDescriptor() {
const { t } = useTranslation();
@@ -35,6 +32,7 @@ function FilesPanel() {
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "files", "FilesPanel requires files target");
const onOpenFile = useCallback(
(path: string) => retargetCurrentTab({ kind: "file", path }),
@@ -42,11 +40,12 @@ function FilesPanel() {
);
if (!workspaceRoot) {
return (
<View style={CENTERED_PADDED_STYLE}>
<View style={styles.centerState}>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}
if (isCompact) {
return (
<FileExplorerPane
serverId={serverId}
@@ -57,6 +56,21 @@ function FilesPanel() {
/>
);
}
return (
<TreeRail testID="files-tree-rail" width={FILE_TREE_WIDTH}>
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.files.chooseFile")}</Text>
</View>
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
</TreeRail>
);
}
export const filesPanelRegistration: PanelRegistration<"files"> = {
kind: "files",
@@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = {
component: FilesPanel,
useDescriptor: useFilesPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
+2 -6
View File
@@ -20,7 +20,6 @@ import {
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
@@ -441,7 +440,6 @@ export function InlineReviewEditor({
}) {
const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle | null>(null);
const focus = useWorkspaceFocusRestoration();
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();
@@ -452,13 +450,11 @@ export function InlineReviewEditor({
}, []);
const handleFocus = useCallback(() => {
focus.unfocus();
setIsFocused(true);
}, [focus]);
}, []);
const handleBlur = useCallback(() => {
setIsFocused(false);
focus.restore();
}, [focus]);
}, []);
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
useEffect(() => {
@@ -106,8 +106,10 @@ import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useSettings } from "@/hooks/use-settings";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
const DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
@@ -1169,6 +1171,10 @@ function ResolvedWorkspaceDesktopTabsRow({
}: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const router = useRouter();
const isCompact = useIsCompactFormFactor();
const openInSidePanelByDefault = useSettings(
(settings) => settings.openSupportingTabsInSidePanel,
);
const newTabKeys = useShortcutKeys("workspace-tab-new");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
@@ -1450,6 +1456,14 @@ function ResolvedWorkspaceDesktopTabsRow({
[openNewTab, paneId, workspaceKey],
);
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
const handleOpenChangesShortcut = useCallback(() => {
openSupportingTab({
isCompact,
workspaceKey,
target: CHANGES_TARGET,
openInSidePanelByDefault,
});
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
const handleOpenPullRequest = useCallback(
() => openPanelTarget(PULL_REQUEST_TARGET),
@@ -1484,7 +1498,7 @@ function ResolvedWorkspaceDesktopTabsRow({
case "workspace.tab.target.changes":
if (!isGit) return true;
setNewTabMenuOpen(false);
handleOpenChanges();
handleOpenChangesShortcut();
return true;
case "workspace.tab.target.files":
setNewTabMenuOpen(false);
@@ -1497,7 +1511,7 @@ function ResolvedWorkspaceDesktopTabsRow({
[
handleCreateAgentTab,
handleCreateBrowser,
handleOpenChanges,
handleOpenChangesShortcut,
handleOpenFiles,
isFocused,
isGit,
@@ -1,4 +1,4 @@
import { memo, useCallback, type ReactElement, type ReactNode } from "react";
import { memo, useCallback, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
@@ -11,6 +11,7 @@ import {
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { Theme } from "@/styles/theme";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -31,6 +32,7 @@ const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMappin
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
const LAUNCHER_ROW_DATA_SET = { emptyPaneLauncherRow: "true" };
/** The launcher's own binding, or nothing when the action has no shortcut. */
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
return keys ? <Shortcut chord={keys} /> : null;
}
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
function getLauncherRowStyle({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
return [
styles.row,
(hovered || focused) && styles.rowHovered,
focused && styles.rowFocused,
pressed && styles.pressed,
];
}
/**
@@ -62,6 +75,8 @@ function LauncherRow({
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
dataSet={LAUNCHER_ROW_DATA_SET}
tabIndex={-1}
onPress={onPress}
style={getLauncherRowStyle}
>
@@ -75,6 +90,8 @@ function LauncherRow({
export interface WorkspacePaneEmptyStateProps {
paneId: string;
/** Logical pane focus. On desktop, the launcher turns this into DOM focus. */
isFocused: boolean;
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
showChanges: boolean;
showPullRequest: boolean;
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
paneId,
isFocused,
showChanges,
showPullRequest,
onOpenView,
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
onClosePane,
}: WorkspacePaneEmptyStateProps): ReactElement {
const { t } = useTranslation();
const containerRef = useRef<View | null>(null);
useEffect(() => {
if (!isWeb || !isFocused) return;
const container = containerRef.current;
if (!(container instanceof HTMLElement)) return;
const handleKeyDown = (event: KeyboardEvent) => {
const rows = Array.from(container.querySelectorAll<HTMLElement>(LAUNCHER_ROW_SELECTOR));
if (rows.length === 0) return;
const currentIndex = rows.findIndex((row) => row === document.activeElement);
let nextIndex: number | null = null;
if (event.key === "ArrowDown") {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % rows.length;
}
if (event.key === "ArrowUp") {
nextIndex =
currentIndex < 0 ? rows.length - 1 : (currentIndex - 1 + rows.length) % rows.length;
}
if (nextIndex !== null) {
event.preventDefault();
event.stopPropagation();
rows[nextIndex]?.focus();
return;
}
if (event.key === "Enter" && currentIndex >= 0) {
event.preventDefault();
event.stopPropagation();
rows[currentIndex]?.click();
}
};
container.addEventListener("keydown", handleKeyDown);
container.querySelector<HTMLElement>(LAUNCHER_ROW_SELECTOR)?.focus({ preventScroll: true });
return () => container.removeEventListener("keydown", handleKeyDown);
}, [isFocused]);
const openFiles = useCallback(
() => onOpenView({ paneId, target: FILES_TARGET }),
@@ -119,16 +173,9 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
);
return (
<View style={styles.container} testID="workspace-pane-empty-state">
<View ref={containerRef} style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}>
<View style={styles.stack}>
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showChanges ? (
<LauncherRow
label={t("workspace.tabs.actions.changes")}
@@ -138,6 +185,13 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
<LauncherShortcut actionId="workspace-tab-target-changes" />
</LauncherRow>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showPullRequest ? (
<LauncherRow
label={t("workspace.tabs.actions.pullRequest")}
@@ -146,14 +200,14 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
/>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.newAgent")}
label={t("workspace.tabs.fallback.agent")}
icon={AGENT_ICON}
onPress={createAgent}
>
<LauncherShortcut actionId="workspace-tab-target-agent" />
</LauncherRow>
<LauncherRow
label={t("workspace.tabs.actions.newTerminal")}
label={t("workspace.tabs.fallback.terminal")}
icon={TERMINAL_ICON}
onPress={createTerminal}
>
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 44,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
backgroundColor: theme.colors.surface2,
},
rowHovered: {
backgroundColor: theme.colors.surface3,
},
rowFocused: {
borderColor: theme.colors.borderAccent,
},
pressed: {
opacity: 0.85,
},
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
import {
isSidePanelOpen,
openSupportingTab,
openTabInSidePanel,
toggleSidePanel,
useIsSidePanelOpen,
} from "@/workspace-tabs/side-panel";
@@ -76,7 +77,10 @@ import {
import { useSettings } from "@/hooks/use-settings";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
KeyboardActionDefinition,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
buildDeterministicWorkspaceTabId,
@@ -1423,6 +1427,17 @@ function useWorkspaceTerminalTabActions({
};
}
function resolveCommandCenterPanelTarget(target: WorkspacePanelTarget): WorkspaceTabTarget {
switch (target) {
case "changes":
return { kind: "working_diff" };
case "files":
return { kind: "files" };
case "pull-request":
return { kind: "pull_request" };
}
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -2842,6 +2857,109 @@ function WorkspaceScreenContent({
],
);
const handleWorkspacePanelOpenAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "workspace.tab.open") return false;
if (!persistenceKey) return true;
const target = resolveCommandCenterPanelTarget(action.target);
if (action.placement === "supporting") {
openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target,
openInSidePanelByDefault,
});
return true;
}
if (action.placement === "side-panel") {
openTabInSidePanel({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
target,
});
return true;
}
const focusedPaneId = focusedPaneTabState.pane?.id;
openWorkspaceTabFocused(
persistenceKey,
target,
focusedPaneId ? { mode: "pane", paneId: focusedPaneId } : undefined,
);
return true;
},
[
activeExplorerCheckout,
focusedPaneTabState.pane?.id,
isMobile,
openInSidePanelByDefault,
openWorkspaceTabFocused,
persistenceKey,
],
);
const handleWorkspaceCurrentTabMetadataAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
const descriptor = activeTab?.descriptor;
switch (action.id) {
case "workspace.tab.rename-current":
if (descriptor) handleRenameTab(descriptor);
return true;
case "workspace.tab.reload-current":
if (descriptor?.target.kind === "agent")
void handleReloadAgent(descriptor.target.agentId);
return true;
case "workspace.tab.copy-resume-command":
if (descriptor?.target.kind === "agent") {
void handleCopyResumeCommand(descriptor.target.agentId);
}
return true;
case "workspace.tab.copy-id":
if (descriptor?.target.kind === "agent")
void handleCopyAgentId(descriptor.target.agentId);
if (descriptor?.target.kind === "terminal") {
void handleCopyTerminalId(descriptor.target.terminalId);
}
return true;
case "workspace.tab.copy-file-path":
if (descriptor?.target.kind === "file") void handleCopyFilePath(descriptor.target.path);
return true;
default:
return false;
}
},
[
activeTab,
handleCopyAgentId,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleReloadAgent,
handleRenameTab,
],
);
const handleWorkspaceCurrentTabCloseAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!activeTabId) return true;
if (action.id === "workspace.tab.close-left") {
void handleCloseTabsToLeft(activeTabId);
return true;
}
if (action.id === "workspace.tab.close-right") {
void handleCloseTabsToRight(activeTabId);
return true;
}
if (action.id === "workspace.tab.close-others") {
void handleCloseOtherTabs(activeTabId);
return true;
}
return false;
},
[activeTabId, handleCloseOtherTabs, handleCloseTabsToLeft, handleCloseTabsToRight],
);
const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
switch (action.id) {
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
],
);
const workspaceKeyboardActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-tab-actions",
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
"workspace.terminal.new",
"workspace.browser.new",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceTabAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-panel-open-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: ["workspace.tab.open"] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePanelOpenAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-current-tab-metadata-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: [
"workspace.tab.rename-current",
"workspace.tab.reload-current",
"workspace.tab.copy-resume-command",
"workspace.tab.copy-id",
"workspace.tab.copy-file-path",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabMetadataAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-current-tab-close-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: [
"workspace.tab.close-left",
"workspace.tab.close-right",
"workspace.tab.close-others",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabCloseAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-pane-actions",
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
function renderSplitPaneEmptyState(input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) {
// Same predicate the close action reads, so the button is offered exactly when
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
return (
<WorkspacePaneEmptyState
paneId={input.paneId}
isFocused={input.isFocused}
showChanges={isGitCheckout}
showPullRequest={hasPullRequest}
onOpenView={handleOpenPaneView}
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
};
}
let tabId = input.createTabId();
if (currentTab?.target.kind === "draft") {
// File navigation is a retarget of one File tab, not a new panel instance. Keeping its
// identity preserves the mounted tab track and the File tab's local presentation state.
if (
currentTab?.target.kind === "draft" ||
(currentTab?.target.kind === "file" && input.target.kind === "file")
) {
tabId = input.tabId;
} else if (input.target.kind === "draft") {
tabId = buildDeterministicWorkspaceTabId(input.target);
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
store.setTabState(workspaceKey, file, fileState);
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
expect(sameKind).toBe(file);
expect(
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
(tab) => tab.tabId === sameKind,
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
{ kind: "file", path: "/repo/b.ts" },
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
);
expect(replacement).not.toBe(file);
expect(replacement).toBe(file);
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined();
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
store.closeTab(workspaceKey, replacement as string);
expect(
@@ -31,6 +31,7 @@ import {
openSupportingTab,
showSidePanel,
toggleSidePanel,
toggleSupportingTab,
} from "@/workspace-tabs/side-panel";
const WORKSPACE_KEY = "server-1:ws-main";
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
expect(isSidePanelOpen(wide)).toBe(false);
expect(tabKinds()).toEqual(["files"]);
});
it("toggles the visible supporting target without closing its tab", () => {
const changes = {
...wide,
target: { kind: "working_diff" } as const,
openInSidePanelByDefault: true,
};
const tabId = toggleSupportingTab(changes);
openSidePanelView({ ...wide, view: "files" });
toggleSupportingTab(changes);
expect(isSidePanelOpen(wide)).toBe(true);
expect(focusedPaneTabKinds()).toContain("working_diff");
toggleSupportingTab(changes);
expect(isSidePanelOpen(wide)).toBe(false);
expect(tabKinds()).toContain("working_diff");
expect(toggleSupportingTab(changes)).toBe(tabId);
expect(isSidePanelOpen(wide)).toBe(true);
});
});
describe("non-compact without splits", () => {
@@ -14,6 +14,7 @@ import {
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
/**
* The side panel is the companion surface beside the user's work — Changes, Files,
@@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean {
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
}
function isTargetVisibleInSidePanel(
state: LayoutState,
workspaceKey: string,
target: WorkspaceTabTarget,
): boolean {
if (!selectIsSidePanelVisible(state, workspaceKey)) return false;
const layout = state.layoutByWorkspace[workspaceKey];
const paneId = selectSidePanelPaneId(state, workspaceKey);
const focusedTabId = layout && paneId ? findPaneById(layout.root, paneId)?.focusedTabId : null;
if (!layout || !focusedTabId) return false;
const focusedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === focusedTabId);
return Boolean(focusedTab && workspaceTabTargetsEqual(focusedTab.target, target));
}
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
/**
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
background?: boolean;
}
export interface ToggleSupportingTabInput extends SidePanelQuery {
target: WorkspaceTabTarget;
openInSidePanelByDefault: boolean;
}
/**
* Opens a tab the user did not place themselves — an agent's file link, a detected
* pull request, workspace setup progress. A supporting tab that is already open is
@@ -123,6 +143,52 @@ export function openSupportingTab(input: OpenSupportingTabInput): string | null
});
}
/** Toggle a supporting target when it is the visible Side panel tab; otherwise reveal it. */
export function toggleSupportingTab(input: ToggleSupportingTabInput): string | null {
if (!input.workspaceKey) return null;
const store = useWorkspaceLayoutStore.getState();
if (
input.openInSidePanelByDefault &&
canUseSidePanelPane(input) &&
isTargetVisibleInSidePanel(store, input.workspaceKey, input.target)
) {
store.hideSidePanel(input.workspaceKey);
return null;
}
return openSupportingTab(input);
}
/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */
export function openTabInSidePanel(
input: SidePanelInput & { target: WorkspaceTabTarget },
): string | null {
if (!input.workspaceKey) {
return null;
}
if (input.isCompact) {
const view = Object.entries(SIDE_PANEL_VIEW_TARGETS).find(
([, target]) => target.kind === input.target.kind,
)?.[0] as ExplorerTab | undefined;
if (!view || !input.checkout) {
return null;
}
const panel = usePanelStore.getState();
panel.setExplorerTabForCheckout({ ...input.checkout, tab: view });
panel.openCompactFileExplorer(input.checkout);
return null;
}
const store = useWorkspaceLayoutStore.getState();
const paneId = canUseSidePanelPane(input) ? store.showSidePanel(input.workspaceKey) : undefined;
return store.openTab({
workspaceKey: input.workspaceKey,
target: input.target,
intent: "reveal",
placement: paneId ? { mode: "pane", paneId } : undefined,
});
}
/**
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
+10 -2
View File
@@ -87,6 +87,7 @@ describe("Hub commands", () => {
assert.deepEqual(events, [
"progress:Logging in to https://hub.paseo.sh",
"authorize:https://hub.paseo.sh",
"progress:Logged in",
]);
assert.equal(result.data.origin, "https://hub.paseo.sh");
});
@@ -113,11 +114,18 @@ describe("Hub commands", () => {
events.push("init");
events.push("deploy");
},
reporter: quietReporter,
reporter: { progress: (message) => events.push(`progress:${message}`) },
},
);
assert.deepEqual(events, ["login", "connect:https://hub.test", "init", "deploy"]);
assert.deepEqual(events, [
"progress:Logging in to https://hub.test",
"login",
"progress:Logged in",
"connect:https://hub.test",
"init",
"deploy",
]);
});
it("JSON and noninteractive login remain login-only", async () => {
+99 -18
View File
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
import type { HubHttpClient } from "./hub-client/index.js";
@@ -24,11 +25,7 @@ describe("Hub guided setup continuation", () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers(
[true, true],
["codex", "gpt-5", "full-access", "slack"],
["U123"],
);
const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
@@ -46,24 +43,22 @@ describe("Hub guided setup continuation", () => {
);
assert.deepEqual(prompts.confirmations, [
"Connect this daemon to https://hub.test?",
"Connect this daemon to this Hub?",
"Initialize and deploy a starter workflow?",
]);
assert.deepEqual(prompts.selections, [
"Starter agent provider",
"Starter agent model",
"Starter agent mode",
"Trigger provider",
]);
assert.deepEqual(prompts.selectionOptions, [
["Codex (suggested)"],
["Codex"],
["GPT-5 (suggested)", "GPT-5 mini"],
["Read only", "Full access (suggested)"],
[
"GitHub (issue or pull request comment)",
"Slack (channel mention)",
"Discord (channel mention)",
],
]);
assert.deepEqual(prompts.messages, [
"Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.",
"Using Slack — Paseo",
]);
assert.deepEqual(calls, [
{ operation: "token", origin: "https://hub.test" },
@@ -111,6 +106,36 @@ describe("Hub guided setup continuation", () => {
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
});
it("keeps login successful when no Hub app connection can initialize a workflow", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true, true], [], []);
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => true,
continueGuidedSetup: (origin) =>
continueHubGuidedSetup(
origin,
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
),
reporter: { progress() {} },
},
);
assert.deepEqual(prompts.messages, [
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
]);
});
it("keeps hub init's existing replacement confirmation", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
@@ -140,7 +165,7 @@ describe("Hub guided setup continuation", () => {
},
]);
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto", "slack"], ["U123"]);
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto"], ["U123"]);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
@@ -162,6 +187,52 @@ describe("Hub guided setup continuation", () => {
["validate", "install"],
);
});
it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([], [], []);
await assert.rejects(
runHubGuidedSetup(
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
{ origin: "https://hub.test", daemonId: "daemon-1", deploy: true },
),
/No Hub app connection is ready for this workflow/u,
);
assert.equal(daemon.snapshotCwds.length, 0);
await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" });
});
it("waits for fresh-daemon provider discovery before offering runtime choices", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const ready = new SetupDaemon().providerEntries;
const daemon = new SetupDaemon(ready, [
[{ provider: "codex", status: "loading", enabled: true }],
ready,
]);
const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]);
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), {
origin: "https://hub.test",
daemonId: "daemon-1",
deploy: true,
});
assert.equal(daemon.snapshotCwds.length, 2);
assert.deepEqual(prompts.selections.slice(0, 3), [
"Starter agent provider",
"Starter agent model",
"Starter agent mode",
]);
});
});
function setupEnvironment(
@@ -170,6 +241,9 @@ function setupEnvironment(
daemon: SetupDaemon,
prompts: PromptAnswers,
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
options: {
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
} = {},
): HubGuidedSetupEnvironment {
const hub = {
async issueEnrollmentToken(origin: string) {
@@ -182,11 +256,13 @@ function setupEnvironment(
},
async listSetupResources(origin: string) {
calls.push({ operation: "setup", origin });
return {
return (
options.setupResources ?? {
github: [],
discord: [],
slack: [{ teamId: "T123", teamName: "Paseo" }],
};
}
);
},
async listConfigurationResources(origin: string) {
calls.push({ operation: "resources", origin });
@@ -294,7 +370,7 @@ class SetupDaemon implements HubDaemonClient {
private origin: string | null = null;
constructor(
private readonly providerEntries = [
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready" as const,
@@ -311,6 +387,7 @@ class SetupDaemon implements HubDaemonClient {
defaultModeId: "full-access",
},
],
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
) {}
async connectHub(origin: string) {
@@ -325,7 +402,11 @@ class SetupDaemon implements HubDaemonClient {
async getProvidersSnapshot(options?: { cwd?: string }) {
this.snapshotCwds.push(options?.cwd);
return { entries: this.providerEntries };
const index = Math.min(
this.snapshotCwds.length - 1,
(this.providerEntrySequence?.length ?? 1) - 1,
);
return { entries: this.providerEntrySequence?.[index] ?? this.providerEntries };
}
async disconnectHub() {
+111 -80
View File
@@ -34,7 +34,6 @@ import {
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
type HubInitProvider,
} from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.js";
import { runHubConnect } from "./connect.js";
@@ -43,16 +42,21 @@ import { runHubProjects } from "./projects.js";
import type { HubReporter } from "./reporter.js";
import { normalizeHubOrigin } from "./origin.js";
import {
availableStarterAgentProviders,
selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice,
type HubStarterAgentProvider,
type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js";
import {
availableStarterTriggerConnections,
type HubStarterTriggerConnection,
} from "./starter-trigger.js";
const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>;
@@ -132,16 +136,18 @@ export async function runHubGuidedSetup(
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
const project = await chooseProject(origin, environment);
const resources = await loadSetupResources(origin, environment);
const triggerConnections = await resolveStarterTriggerConnections(resources, cwd);
reportStarterTriggerConnections(environment, triggerConnections);
const trigger = await chooseStarterTriggerConnection(environment, triggerConnections);
const daemon = await loadDaemonResource(origin, daemonId, environment);
log.success(`Connected as ${daemon.slug}`);
const agent = await chooseStarterAgentRuntime(environment, cwd);
const provider = await chooseProvider(environment);
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
const providerFilters = await collectProviderIdentity(trigger, environment);
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: daemon.slug,
agent,
provider,
provider: trigger.provider,
providerFilters,
});
@@ -185,7 +191,7 @@ export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
reportMessage(
environment,
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
@@ -200,7 +206,20 @@ export async function continueHubGuidedSetup(
);
return;
}
try {
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
} catch (error) {
if (error instanceof HubInitCancelledError) {
if (environment.prompts === undefined) {
log.message(error.message);
outro("Connect an app to continue");
} else {
environment.prompts.message(error.message);
}
return;
}
throw error;
}
}
async function ensureLogin(
@@ -370,31 +389,61 @@ async function chooseProject(
return project;
}
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
return requiredSelect(environment, {
message: "Trigger provider",
options: [
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
{ value: "slack", label: "Slack", hint: "channel mention" },
{ value: "discord", label: "Discord", hint: "channel mention" },
],
async function resolveStarterTriggerConnections(
resources: HubSetupResources,
cwd: string,
): Promise<HubStarterTriggerConnection[]> {
const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
const connections = availableStarterTriggerConnections(resources, repository);
if (connections.length === 0) {
throw new HubInitCancelledError(
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
);
}
return connections;
}
function reportStarterTriggerConnections(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): void {
const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`;
if (environment.prompts === undefined) {
note(details, "Hub app connections ready for this workflow");
return;
}
environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`);
}
async function chooseStarterTriggerConnection(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): Promise<HubStarterTriggerConnection> {
if (connections.length === 1) {
const connection = connections[0]!;
reportMessage(environment, `Using ${connection.label}`);
return connection;
}
const id = await requiredSelect(environment, {
message: "Trigger connection",
options: connections.map((connection) => ({ value: connection.id, label: connection.label })),
});
const connection = connections.find((candidate) => candidate.id === id);
if (connection === undefined) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_INVALID",
"The selected Hub app connection is no longer available. Run paseo hub init again.",
);
}
return connection;
}
async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<HubStarterAgentRuntime> {
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) =>
daemon.getProvidersSnapshot({ cwd }),
);
const providers = availableStarterAgentProviders(snapshot.entries);
if (providers.length === 0) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_REQUIRED",
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
);
}
const providers = await waitForStarterAgentProviders(environment, cwd);
const provider = await chooseStarterAgentProvider(environment, providers);
const model = await chooseStarterAgentModel(environment, provider);
const mode = await chooseStarterAgentMode(environment, provider);
@@ -408,18 +457,45 @@ async function chooseStarterAgentRuntime(
return runtime;
}
async function waitForStarterAgentProviders(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<readonly HubStarterAgentProvider[]> {
return withSpinner("Discovering agent runtimes", async (reporter) =>
withHubDaemon(environment.daemon, undefined, async (daemon) => {
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
while (true) {
const snapshot = await daemon.getProvidersSnapshot({ cwd });
const state = starterAgentProviderSnapshotState(snapshot.entries);
if (state.kind === "ready") return state.providers;
if (state.kind === "unavailable") {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_REQUIRED",
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
);
}
if (Date.now() >= deadline) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_TIMEOUT",
"Agent runtime discovery did not finish within 60 seconds. Check the daemon's provider configuration, then run paseo hub init again.",
);
}
reporter.progress("Waiting for agent runtime discovery");
await delay(DAEMON_READY_POLL_MS);
}
}),
);
}
async function chooseStarterAgentProvider(
environment: HubGuidedSetupEnvironment,
providers: readonly HubStarterAgentProvider[],
): Promise<HubStarterAgentProvider> {
const suggested = suggestedStarterAgentChoice(providers);
const selected = await requiredSelect(environment, {
message: "Starter agent provider",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
...(provider.suggested ? { hint: "suggested" } : {}),
})),
});
const provider = providers.find((candidate) => candidate.id === selected);
@@ -471,57 +547,30 @@ function invalidStarterAgentSelection(): HubCommandError {
);
}
async function collectProviderFilters(
provider: HubInitProvider,
resources: HubSetupResources,
cwd: string,
async function collectProviderIdentity(
trigger: HubStarterTriggerConnection,
environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> {
if (provider === "github") {
const [login, originRemote] = await Promise.all([
readGhValue(["api", "user", "--jq", ".login"]),
readCommandValue("git", ["remote", "get-url", "origin"], cwd),
]);
const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
if (repo === undefined) {
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_UNDETECTED",
"Could not detect a GitHub repository from the origin remote.",
);
}
if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
const connected = resources.github.flatMap(({ repositories }) => repositories);
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_NOT_CONNECTED",
`${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`,
);
}
if (trigger.provider === "github") {
const login = await readGhValue(["api", "user", "--jq", ".login"]);
return {
...trigger.filters,
user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login,
}),
repo,
};
}
if (provider === "slack") {
if (trigger.provider === "slack") {
return {
workspace: await chooseConnection(
environment,
"Slack workspace",
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
),
...trigger.filters,
user: await requiredText(environment, {
message: "Your Slack member ID (only this user can trigger the bot)",
}),
};
}
return {
guild: await chooseConnection(
environment,
"Discord server",
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
),
...trigger.filters,
user: await requiredText(environment, {
message: "Your Discord user ID (only this user can trigger the bot)",
}),
@@ -579,24 +628,6 @@ async function loadDaemonResource(
return daemon;
}
async function chooseConnection(
environment: HubGuidedSetupEnvironment,
message: string,
connections: readonly { id: string; label: string }[],
): Promise<string> {
if (connections.length === 0) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_REQUIRED",
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
);
}
if (connections.length === 1) return connections[0]!.id;
return requiredSelect(environment, {
message,
options: connections.map(({ id, label }) => ({ value: id, label })),
});
}
async function writeScaffold(
cwd: string,
scaffold: ReturnType<typeof createHubInitScaffold>,
+1
View File
@@ -42,6 +42,7 @@ export async function runHubLogin(
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
const credential = await dependencies.flow.authorize(origin);
dependencies.credentials.save({ origin, credential });
reportHubProgress(dependencies.reporter, options, "Logged in");
if (
!options.json &&
dependencies.isInteractive?.() &&
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
availableStarterAgentProviders,
selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice,
} from "./starter-agent-runtime.js";
@@ -30,7 +31,6 @@ describe("starter agent runtime choices", () => {
{
id: "codex",
label: "Codex",
suggested: true,
models: [
{ id: "gpt-5", label: "GPT-5", suggested: true },
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
@@ -59,7 +59,6 @@ describe("starter agent runtime choices", () => {
{
id: "claude",
label: "Claude",
suggested: false,
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }],
},
@@ -81,7 +80,6 @@ describe("starter agent runtime choices", () => {
{
id: "claude",
label: "claude",
suggested: false,
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }],
},
@@ -118,4 +116,16 @@ describe("starter agent runtime choices", () => {
});
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
});
it("distinguishes discovery in progress from a terminally unusable snapshot", () => {
expect(starterAgentProviderSnapshotState([])).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([{ provider: "claude", status: "loading", enabled: true }]),
).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([
{ provider: "claude", status: "unavailable", enabled: true },
]),
).toEqual({ kind: "unavailable" });
});
});
@@ -11,7 +11,6 @@ export interface HubStarterAgentProvider {
label: string;
models: readonly HubStarterAgentModel[];
modes: readonly HubStarterAgentMode[];
suggested: boolean;
}
export interface HubStarterAgentModel {
@@ -26,6 +25,11 @@ export interface HubStarterAgentMode {
suggested: boolean;
}
export type HubStarterAgentProviderSnapshotState =
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
| { kind: "loading" }
| { kind: "unavailable" };
export function availableStarterAgentProviders(
entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProvider[] {
@@ -47,14 +51,23 @@ export function availableStarterAgentProviders(
label: entry.label ?? entry.provider,
models,
modes,
suggested:
models.some((model) => model.suggested) &&
(modes.length === 0 || modes.some((mode) => mode.suggested)),
},
];
});
}
export function starterAgentProviderSnapshotState(
entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProviderSnapshotState {
const providers = availableStarterAgentProviders(entries);
if (providers.length > 0) return { kind: "ready", providers };
if (entries.length === 0) return { kind: "loading" };
if (entries.some((entry) => entry.enabled && entry.status === "loading")) {
return { kind: "loading" };
}
return { kind: "unavailable" };
}
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
choices: readonly T[],
): T | undefined {
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { availableStarterTriggerConnections } from "./starter-trigger.js";
describe("starter trigger connections", () => {
it("returns only concrete connections that can back the generated workflow", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/paseo"],
},
],
slack: [{ teamId: "T123", teamName: "Paseo" }],
discord: [{ guildId: "456", guildName: "Paseo Discord" }],
},
"getpaseo/paseo",
),
).toEqual([
{
id: "github:getpaseo/paseo",
label: "GitHub — getpaseo/paseo",
provider: "github",
filters: { repo: "getpaseo/paseo" },
},
{
id: "slack:T123",
label: "Slack — Paseo",
provider: "slack",
filters: { workspace: "T123" },
},
{
id: "discord:456",
label: "Discord — Paseo Discord",
provider: "discord",
filters: { guild: "456" },
},
]);
});
it("does not offer GitHub when the current repository is not connected", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/hub"],
},
],
slack: [],
discord: [],
},
"getpaseo/paseo",
),
).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import type { HubSetupResources } from "./hub-client/index.js";
import type { HubInitProvider } from "./init-plan.js";
export interface HubStarterTriggerConnection {
id: string;
label: string;
provider: HubInitProvider;
filters: Readonly<Record<string, string>>;
}
export function availableStarterTriggerConnections(
resources: HubSetupResources,
githubRepository?: string,
): HubStarterTriggerConnection[] {
return [
...(githubRepository !== undefined &&
resources.github.some(({ repositories }) => repositories.includes(githubRepository))
? [
{
id: `github:${githubRepository}`,
label: `GitHub — ${githubRepository}`,
provider: "github" as const,
filters: { repo: githubRepository },
},
]
: []),
...resources.slack.map(({ teamId, teamName }) => ({
id: `slack:${teamId}`,
label: `Slack — ${teamName}`,
provider: "slack" as const,
filters: { workspace: teamId },
})),
...resources.discord.map(({ guildId, guildName }) => ({
id: `discord:${guildId}`,
label: `Discord — ${guildName}`,
provider: "discord" as const,
filters: { guild: guildId },
})),
];
}
+55
View File
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_file_budget_compat",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "large.txt",
mode: "file",
acceptBinary: true,
maxBytes: 10,
requestId: "req-budget",
},
});
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-budget",
metadata: {
mime: "text/plain",
size: 100,
encoding: "utf-8",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-budget",
payload: new Uint8Array(100),
}),
);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
);
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+41 -3
View File
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
interface PendingBinaryFileRead {
cwd: string;
path: string;
maxBytes?: number;
}
interface BinaryFileTransferState extends PendingBinaryFileRead {
@@ -4308,6 +4309,7 @@ export class DaemonClient {
mode: "list" | "file",
requestId?: string,
acceptBinary = false,
maxBytes?: number,
): Promise<FileExplorerPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4317,6 +4319,7 @@ export class DaemonClient {
path,
mode,
...(acceptBinary ? { acceptBinary: true } : {}),
...(maxBytes ? { maxBytes } : {}),
},
responseType: "file_explorer_response",
});
@@ -4337,11 +4340,23 @@ export class DaemonClient {
return payload.directory;
}
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
async readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
): Promise<FileReadResult> {
const resolvedRequestId = this.createRequestId(requestId);
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
try {
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
const payload = await this.requestFileExplorer(
cwd,
path,
"file",
resolvedRequestId,
true,
maxBytes,
);
if (payload.error) {
throw new Error(payload.error);
}
@@ -5761,10 +5776,33 @@ export class DaemonClient {
}
if (frame.opcode === FileTransferOpcode.FileChunk) {
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
// Old daemons stream despite maxBytes; discard before client-side accumulation.
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
return;
}
transfer.chunks.push(frame.payload);
return;
}
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
this.activeBinaryFileTransfers.delete(frame.requestId);
this.handleSessionMessage({
type: "file_explorer_response",
payload: {
cwd: transfer.cwd,
path: transfer.path,
mode: "file",
directory: null,
file: null,
error: "File is too large to display",
requestId: frame.requestId,
},
});
return;
}
const bytes = concatByteChunks(transfer.chunks, transfer.size);
this.activeBinaryFileTransfers.delete(frame.requestId);
this.completedBinaryFileReads.set(frame.requestId, {
+1
View File
@@ -2555,6 +2555,7 @@ export const FileExplorerRequestSchema = z.object({
mode: z.enum(["list", "file"]),
requestId: z.string(),
acceptBinary: z.boolean().optional(),
maxBytes: z.number().int().positive().optional(),
});
export const FileVersionSchema = z.discriminatedUnion("status", [
+48
View File
@@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string {
return new TextDecoder().decode(decrypt(sharedKey, ciphertext));
}
function bytesFromHex(hex: string): Uint8Array {
return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16));
}
describe("crypto", () => {
describe("generateKeyPair", () => {
it("generates a valid keypair", () => {
@@ -36,6 +40,22 @@ describe("crypto", () => {
const reExported = exportPublicKey(imported);
expect(reExported).toBe(exported);
});
it.each([
["malformed", "!".repeat(43) + "="],
["noncanonical", `${"A".repeat(42)}B=`],
])("rejects %s base64", (_description, encoded) => {
expect(importPublicKey.bind(null, encoded)).toThrowError("Invalid public key encoding");
});
it.each([
["short", new Uint8Array(31)],
["oversized", new Uint8Array(33)],
])("rejects a %s decoded key", (_description, key) => {
expect(importPublicKey.bind(null, Buffer.from(key).toString("base64"))).toThrowError(
"Invalid public key length (expected 32)",
);
});
});
describe("deriveSharedKey", () => {
@@ -63,6 +83,34 @@ describe("crypto", () => {
expect(decrypted).toBe(testMessage);
});
// Unsupported peer public keys.
it.each([
["unsupported key 1", "00".repeat(32)],
["unsupported key 2", `01${"00".repeat(31)}`],
["unsupported key 3", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"],
["unsupported key 4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"],
["unsupported key 5", `ec${"ff".repeat(30)}7f`],
["unsupported key 6", `ed${"ff".repeat(30)}7f`],
["unsupported key 7", `ee${"ff".repeat(30)}7f`],
])("rejects the %s peer public key", (_description, publicKeyHex) => {
const { secretKey } = generateKeyPair();
expect(deriveSharedKey.bind(null, secretKey, bytesFromHex(publicKeyHex))).toThrowError(
"Invalid peer public key",
);
});
it.each([
["short", new Uint8Array(31)],
["oversized", new Uint8Array(33)],
])("rejects a %s peer public key", (_description, publicKey) => {
const { secretKey } = generateKeyPair();
expect(deriveSharedKey.bind(null, secretKey, publicKey)).toThrowError(
"Invalid peer public key length (expected 32)",
);
});
});
describe("encrypt / decrypt", () => {
+26 -1
View File
@@ -23,6 +23,7 @@ export interface KeyPair {
export type SharedKey = Uint8Array; // 32 bytes (box.before)
const NONCE_LENGTH = nacl.box.nonceLength; // 24
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);
let prngReady = false;
@@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array {
return toByteArray(base64);
}
function decodePublicKeyBase64(base64: string): Uint8Array {
if (
typeof base64 !== "string" ||
base64.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)
) {
throw new Error("Invalid public key encoding");
}
const bytes = decodeBase64(base64);
if (encodeBase64(bytes) !== base64) {
throw new Error("Invalid public key encoding");
}
return bytes;
}
function toUint8(data: string | ArrayBuffer): Uint8Array {
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
}
@@ -92,7 +109,7 @@ export function exportPublicKey(publicKey: Uint8Array): string {
}
export function importPublicKey(base64: string): Uint8Array {
const bytes = decodeBase64(base64);
const bytes = decodePublicKeyBase64(base64);
if (bytes.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
}
@@ -121,6 +138,14 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
}
const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey);
const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT);
rawSharedResult.fill(0);
if (isAllZero) {
throw new Error("Invalid peer public key");
}
return nacl.box.before(peerPublicKey, ourSecretKey);
}
+2 -2
View File
@@ -69,7 +69,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4",
@@ -78,7 +78,7 @@
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -1,5 +1,9 @@
import { expect, it, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
@@ -8,8 +12,15 @@ import {
formatSystemNotificationPrompt,
isSystemInjectedEnvelope,
setupFinishNotification,
waitForAgentRunStartWithTimeout,
} from "./agent-prompt.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type {
AgentClient,
AgentRunResult,
AgentSession,
AgentStreamEvent,
} from "./agent-sdk-types.js";
interface CapturedLogger {
logger: Logger;
@@ -520,3 +531,234 @@ it("does not notify archived callers", async () => {
expect(streamAgentSpy).not.toHaveBeenCalled();
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
});
// Deliberately independent literals rather than the production constants these tests
// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests
// green if that constant were shortened back under a provider's startup budget.
const EXPECTED_RUN_START_BUDGET_MS = 60_000;
// The slowest provider startup budget the run-start wait has to sit outside of today
// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS).
const SLOWEST_PROVIDER_STARTUP_BUDGET_MS = 30_000;
const RUN_START_TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsSessionListing: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
/**
* Provider session whose turn start is held open for a configurable span, so the real
* AgentManager run-state transition (pendingRun.started -> lifecycle "running" ->
* agent_state) is what the run-start wait observes. `startDelayMs: null` never starts.
*/
class SlowStartAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = RUN_START_TEST_CAPABILITIES;
readonly id = randomUUID();
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private releaseStartTurn!: () => void;
private readonly released = new Promise<void>((resolve) => {
this.releaseStartTurn = resolve;
});
constructor(private readonly startDelayMs: number | null) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
/** Teardown hook so a never-starting turn cannot wedge the suite. */
release(): void {
this.releaseStartTurn();
}
async startTurn(): Promise<{ turnId: string }> {
await new Promise<void>((resolve) => {
if (this.startDelayMs !== null) {
setTimeout(resolve, this.startDelayMs);
}
void this.released.then(resolve);
});
const turnId = "turn-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) {
callback(event);
}
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class SlowStartAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = RUN_START_TEST_CAPABILITIES;
readonly sessions: SlowStartAgentSession[] = [];
constructor(private readonly startDelayMs: number | null) {}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
const session = new SlowStartAgentSession(this.startDelayMs);
this.sessions.push(session);
return session;
}
async fetchCatalog() {
return { models: [], modes: [] };
}
async resumeSession(): Promise<AgentSession> {
return await this.createSession();
}
}
/**
* Real AgentManager driving a real agent, so the run-start wait exercises the production
* run-state and agent_state subscription path rather than a replaced method.
*/
async function createRunStartScenario(startDelayMs: number | null): Promise<{
agentManager: AgentManager;
agentId: string;
startRun: () => Promise<void>;
cleanup: () => Promise<void>;
}> {
const workdir = mkdtempSync(join(tmpdir(), "agent-run-start-budget-"));
const client = new SlowStartAgentClient(startDelayMs);
const agentManager = new AgentManager({
clients: { codex: client },
logger: createTestLogger(),
});
const snapshot = await agentManager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
let drained: Promise<void> = Promise.resolve();
return {
agentManager,
agentId: snapshot.id,
// streamAgent registers the pending run synchronously, so the wait always observes it.
startRun: async () => {
const run = agentManager.streamAgent(snapshot.id, "start the run");
drained = (async () => {
for await (const _event of run) {
// Drain whatever the turn produces.
}
})().catch(() => undefined);
},
cleanup: async () => {
// Release any turn still held open, then close. The drain is deliberately not
// awaited: depending on how far the turn got, the stream ends either from the
// release or from the close, and teardown must not depend on which.
for (const session of client.sessions) {
session.release();
}
await agentManager.closeAgent(snapshot.id).catch(() => undefined);
void drained;
rmSync(workdir, { recursive: true, force: true });
},
};
}
test("waiting for a run start outlasts the slowest provider startup budget", async () => {
// A provider is still allowed to be starting here, so the outer wait must not abort it.
const scenario = await createRunStartScenario(SLOWEST_PROVIDER_STARTUP_BUDGET_MS + 5_000);
vi.useFakeTimers();
try {
await scenario.startRun();
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
let settled = false;
const markSettled = () => {
settled = true;
};
void wait.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(SLOWEST_PROVIDER_STARTUP_BUDGET_MS);
expect(settled).toBe(false);
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).not.toBe("running");
await vi.advanceTimersByTimeAsync(5_000);
await expect(wait).resolves.toBeUndefined();
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).toBe("running");
} finally {
vi.useRealTimers();
await scenario.cleanup();
}
});
test("waiting for a run start still gives up at the run start budget", async () => {
const scenario = await createRunStartScenario(null);
vi.useFakeTimers();
try {
await scenario.startRun();
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
const rejection = expect(wait).rejects.toThrow(
"codex run did not start within 60 seconds (phase: run start)",
);
let settled = false;
const markSettled = () => {
settled = true;
};
void wait.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(EXPECTED_RUN_START_BUDGET_MS - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(settled).toBe(true);
} finally {
vi.useRealTimers();
await scenario.cleanup();
}
});
@@ -212,14 +212,35 @@ export interface StartCreatedAgentInitialPromptParams {
logger: Logger;
}
const AGENT_RUN_START_TIMEOUT_MS = 15_000;
/**
* Outer bound on a run reaching "started" after dispatch.
*
* This wraps provider startup, so it MUST stay larger than the slowest provider's own
* startup budget — otherwise it aborts a start the provider was still allowed to be
* working on, and the provider's budget can never apply. OpenCode is the slowest today:
* up to 30s for the server to boot (OPENCODE_SERVER_STARTUP_TIMEOUT_MS) and then a
* session.create on the same budget, so this is deliberately set well above 30s.
*
* Not derived from the provider constant on purpose: this module is provider-agnostic
* and must not depend on a specific provider's internals.
*/
const AGENT_RUN_START_TIMEOUT_MS = 60_000;
export async function waitForAgentRunStartWithTimeout(
agentManager: AgentManager,
agentId: string,
): Promise<void> {
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
const startAbort = new AbortController();
const startTimeout = setTimeout(() => startAbort.abort("timeout"), AGENT_RUN_START_TIMEOUT_MS);
const startTimeout = setTimeout(
() =>
startAbort.abort(
new Error(
`${provider} run did not start within ${AGENT_RUN_START_TIMEOUT_MS / 1000} seconds (phase: run start)`,
),
),
AGENT_RUN_START_TIMEOUT_MS,
);
try {
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
@@ -343,6 +343,81 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test("creates a session when session.create needs more than ten seconds", async () => {
vi.useFakeTimers();
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
const slowCreate = createTestDeferred<void>();
openCode.sessionCreateImplementation = async () => {
await slowCreate.promise;
return { data: { id: "session-1" } };
};
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const creation = client.createSession(buildConfig(cwd));
let settled = false;
const markSettled = () => {
settled = true;
};
void creation.then(markSettled, markSettled);
// Under CPU contention a per-directory bootstrap has been measured well past ten
// seconds, which the previous budget failed outright.
await vi.advanceTimersByTimeAsync(20_000);
expect(settled).toBe(false);
slowCreate.resolve();
const session = await creation;
expect(session.provider).toBe("opencode");
expect(openCode.calls.sessionCreate).toHaveLength(1);
await session.close();
} finally {
vi.useRealTimers();
slowCreate.resolve();
rmSync(cwd, { recursive: true, force: true });
}
});
test("bounds session.create at the server startup budget", async () => {
vi.useFakeTimers();
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionCreateImplementation = () => new Promise<never>(() => undefined);
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const creation = client.createSession(buildConfig(cwd));
const rejection = expect(creation).rejects.toThrow("OpenCode session.create timed out");
let settled = false;
const markSettled = () => {
settled = true;
};
void creation.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(EXPECTED_STARTUP_BUDGET_MS - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(settled).toBe(true);
expect(runtime.acquisitions.at(-1)?.releaseCount).toBe(1);
} finally {
vi.useRealTimers();
rmSync(cwd, { recursive: true, force: true });
}
});
test("archives and unarchives the durable native session through client hooks", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -1396,10 +1396,15 @@ export class OpenCodeAgentClient implements AgentClient {
});
try {
// Creating the first session for a directory is part of OpenCode coming up, so it
// shares the server startup budget instead of a shorter one that fails agent
// creation on contended cold starts.
const response = await withTimeout(
client.session.create({ directory: openCodeConfig.cwd }),
10_000,
"OpenCode session.create timed out after 10s",
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
`OpenCode session.create timed out after ${Math.round(
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
)}s`,
);
if (response.error) {
@@ -145,6 +145,7 @@ export class TestOpenCodeClient {
sessionCommandEvents: unknown[] = [idleEvent()];
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionCreateImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
sessionDeleteResponse: OpenCodeResponse = {};
sessionChildrenResponses: OpenCodeResponse[] = [];
sessionChildrenImplementation:
@@ -294,6 +295,9 @@ export class TestOpenCodeClient {
},
create: async (parameters: unknown) => {
this.calls.sessionCreate.push(parameters);
if (this.sessionCreateImplementation) {
return await this.sessionCreateImplementation(parameters);
}
return this.sessionCreateResponse;
},
delete: async (parameters: unknown) => {
@@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
// Fixed transport key for an unsupported peer public key.
const UNSUPPORTED_PEER_TRANSPORT_KEY = Uint8Array.from(
Buffer.from("351f86faa3b988468a850122b65b0acece9c4826806aeee63de9c0da2bd7f91e", "hex"),
);
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
return JSON.parse(plaintext);
@@ -161,18 +166,29 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
async function waitForCapturedLog(
lines: string[],
predicate: (line: string) => boolean,
timeout = 1000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (lines.some(predicate)) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for captured log. Recent logs:\n${lines.slice(-20).join("")}`);
}
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
let relayPort: number;
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
const startRelay = async () => {
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
relayStdoutLines = [];
relayPort = await getAvailablePort();
const relayDir = path.resolve(process.cwd(), "../relay");
relayProcess = spawn(
"npx",
[
const relayArgs = [
"wrangler",
"dev",
"--local",
@@ -182,14 +198,16 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
];
if (options.useLocalRelay) {
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
}
relayProcess = spawn("npx", relayArgs, {
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
});
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
@@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
}, 90000);
test("daemon closes a relay client that sends an unsupported handshake key", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
});
});
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
@@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => {
]);
});
test("rejects an over-budget file before opening a binary transfer", async () => {
const cwd = makeDir("workspace-files-read-budget-");
writeFileSync(join(cwd, "notes.txt"), "hello world");
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "notes.txt",
mode: "file",
requestId: "req-read-budget",
acceptBinary: true,
maxBytes: 5,
});
expect(binary).toEqual([]);
expect(emitted).toEqual([
expect.objectContaining({
type: "file_explorer_response",
payload: expect.objectContaining({ error: "File is too large to display" }),
}),
]);
});
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
const cwd = makeDir("workspace-files-large-binary-");
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
source,
);
} else {
if (request.maxBytes) {
const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath });
if (file.size > request.maxBytes) {
throw new Error("File is too large to display");
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
await this.host.emitBinary(
+8 -1
View File
@@ -263,6 +263,7 @@ Use `PASEO_HOME` to run multiple isolated daemon instances.
```bash
paseo hub login [url] # Approve and store organization-scoped CLI access
paseo hub init # Guided setup: scaffold and deploy a starter bundle here
paseo hub connect [url] # Enroll this daemon using CLI access
paseo hub projects # List projects in the authenticated organization
paseo hub status # Show the current Hub relationship
@@ -276,7 +277,13 @@ Run deploy from the project root. It reads `.paseo/hub.yml`, every direct `.pase
Pass `-p, --project <slug>` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count.
`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. The stored login is separate from the daemon relationship created by `connect`. Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved.
`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it then asks whether to connect this daemon and whether to initialize and deploy a starter workflow, both defaulting to yes. Declining the connection prints `paseo hub connect <origin>; then paseo hub init`, because the connection alone does not produce a bundle; declining only the starter prints `paseo hub init`. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`.
`init` runs the same guided setup on its own and requires a TTY. It connects the daemon, uses the organization's only project or asks which one, and lists the Hub app connections that can back a starter workflow. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
Setup then asks which agent provider, model, and mode the starter should run, choosing from what the connected daemon reports. A provider is offered only when the daemon has it enabled with a selectable model. Suggested model and mode entries are the daemon's defaults; no provider is suggested merely because it appears first. The mode question is skipped for providers that expose no modes and asked explicitly when the daemon has modes but no default. Finally, setup asks for the identity that gates the chosen connection: a GitHub username, a Slack member ID, or a Discord user ID. It writes `.paseo/hub.yml` and `.paseo/workflows/<provider>-help.yml`, validates them against Hub, and deploys. An existing `.paseo/` directory is replaced only after you confirm. See the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle).
Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved.
Every command resolves and normalizes its destination before Hub or daemon work. Origin precedence is an explicit command origin or `--hub`, then `PASEO_HUB_URL`, then the active stored login origin, then the hosted default `https://hub.paseo.sh`. The hosted default never overrides an active login. Credential precedence is `--api-key <secret>`, then `PASEO_HUB_API_KEY`, then a stored login for the exact resolved origin. A stored credential is never sent to a different origin. API keys passed through flags or the environment are not stored.
+1 -1
View File
@@ -29,7 +29,7 @@ Work down this list.
1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures.
2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type.
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name.
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. See [find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) and [find your Discord IDs](/docs/hub/triggers/discord#find-your-discord-ids).
4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention.
5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving.
6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`.
+54
View File
@@ -23,6 +23,60 @@ A project configuration is one versioned bundle:
[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists.
## Generated starter bundle
`paseo hub init`, and the guided setup that interactive `paseo hub login` offers, write two files for the directory you run them in:
```yaml
# .paseo/hub.yml
environments:
my-macbook:
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/your-repo
agents:
starter:
provider: codex
model: gpt-5
mode: full-access
```
The environment is named after the connected daemon and points at the directory setup ran in. `provider`, `model`, and `mode` are the runtime you chose from what that daemon reported, so the starter agent is a complete selection before its first run. `mode` is omitted for a provider that exposes none.
```yaml
# .paseo/workflows/slack-help.yml
name: slack-help
on: slack.mention
max_runtime: 2h
filters:
workspace: T01234567
from_users:
- U01234567
steps:
- id: work
environment: my-macbook
max_runtime: 90m
idle_timeout: 10m
agent: starter
prompt:
- text: |
Answer with hub.reply, then complete this request and call hub.finish_execution when done.
<user-prompt>
${{ paseo.prompt }}
</user-prompt>
allow_outputs:
- type: slack.reply
max: 1
required: true
```
`filters` carries the identity each provider matches on: the Slack team and member ID above, the Discord guild and user ID for a Discord starter, and `owner/name` plus your login for GitHub.
A Discord starter is `discord-help.yml` and carries `discord.reply`, the counterpart of the `slack.reply` above. A GitHub starter is `github-help.yml` and declares no reply output; GitHub has no reply capability, so a step that must comment needs a [`github` block](/docs/hub/github) instead.
The generated workflow allows one user in one workspace. Read [Hub security](/docs/hub/security) before widening it.
## Sources
A configuration comes from one source:
+5 -1
View File
@@ -18,7 +18,11 @@ Log in from the machine first:
paseo hub login https://hub.example.com
```
The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. Then enroll the daemon:
The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`.
In an interactive terminal, login then offers to finish setup: whether to connect this daemon, and whether to initialize and deploy a starter workflow. Both default to yes. Declining the connection prints `paseo hub connect <origin>; then paseo hub init`, since connecting alone leaves the project without a workflow. Declining only the starter prints `paseo hub init`. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through the questions.
Enroll the daemon on its own when you declined, or when the machine is already logged in:
```sh
paseo hub connect
+3 -5
View File
@@ -28,18 +28,16 @@ Your daemons keep running agents where they always did. Hub decides when to ask
## What lives in your repository
`paseo hub init` creates a project resource file for environments and agents, plus a safe starter workflow:
Guided setup creates a project resource file for environments and agents, plus one safe starter workflow:
```text
.paseo/
├── hub.yml
└── workflows/
├── slack-help.yml
└── partials/
└── answer.md
└── slack-help.yml
```
Deploy the bundle, mention the bot, and an agent starts on your machine. [Quickstart](/docs/hub/quickstart) runs the guided setup; [Workflows](/docs/hub/workflows) covers routing and provider-specific replies.
Guided setup deploys the bundle, and mentioning the bot starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle) shows what it wrote, and [Workflows](/docs/hub/workflows) covers routing, prompt partials, and provider-specific replies.
## Reading order
+49 -21
View File
@@ -8,7 +8,7 @@ category: Hub
# Hub quickstart
Run Hub locally, connect it to Slack without a public server, and answer a mention with an agent on your machine.
Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and guided setup writes and deploys the workflow for you.
You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app.
@@ -18,13 +18,13 @@ You need [Paseo installed and running](/docs), Node.js, and a Slack workspace wh
npx @getpaseo/hub
```
Open <http://localhost:3000>. Hub uses an embedded database by default, so this first run needs no database, environment variables, or Docker.
Open the address it prints, normally <http://localhost:3000>, and create the operator account Hub asks for.
Create the operator account when Hub welcomes you.
The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database, your organization, and a **Default** project. You never create a project by hand.
## 2. Connect Slack
The next screen explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and does not need a public address or HTTPS.
**Set up your apps** explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and needs no public address or HTTPS.
Paste the App-level token and Bot token back into Hub, then choose **Connect Slack**. Invite the bot to the channel where you will use it:
@@ -32,35 +32,63 @@ Paste the App-level token and Bot token back into Hub, then choose **Connect Sla
/invite @Paseo
```
You can skip GitHub and Discord for now. Their setup remains available under **Apps**.
GitHub and Discord can wait. Their setup stays available under **Apps**.
## 3. Initialize the project
## 3. Connect the machine your code is on
From the repository where the agent should work:
**Connect a daemon** shows one command with this Hub's address already in it:
```sh
paseo hub init
paseo hub login http://localhost:3000
```
Choose **Custom endpoint…** and enter `http://localhost:3000`, then choose Slack. The guided setup:
Run it on the machine where your code lives, in the repository the agent should work in. Guided setup records that directory as the workflow's working directory.
- signs you in through the browser;
- connects the local Paseo daemon;
- uses the default project created during first-run setup;
- selects the connected Slack workspace and asks for your Slack username;
- writes `.paseo/hub.yml` and `.paseo/workflows/slack-help.yml`;
- validates the generated bundle and offers to deploy it.
Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. **Continue** and **Do this later** both land in the Default project.
Accept the default **Deploy now?** choice. The generated workflow accepts mentions only from the username you entered.
## 4. Answer the setup questions
## 4. Mention the bot
Your terminal confirms the login, then picks up where the browser left off. Most questions arrive with a default or a suggested answer; only your Slack member ID has to be typed.
Mention the bot in the channel:
| Question | What it wants |
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
| Connect this daemon to this Hub? | Yes. This enrolls the machine you are on. |
| Initialize and deploy a starter workflow? | Yes. |
| Starter agent provider, model, and mode | What your daemon reports it can run. Suggested model and mode entries are its defaults. |
| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. |
Setup lists the app connections ready for this workflow. Because you connected one Slack workspace in step 2, it selects that connection automatically instead of asking you to choose Slack. If several usable connections exist, setup asks for the **Trigger connection**. If none is ready, it sends you to **Hub → Apps** and stops before asking about the agent or writing files.
The agent provider list contains only runtimes the daemon can use; it does not suggest one arbitrarily. Suggested model and mode entries are defaults reported by the daemon. A provider that has modes but no default mode is still offered; setup asks you to pick the mode instead of guessing one.
[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) has the two clicks that copy your member ID. The Slack workspace comes from the app you connected in step 2, so setup does not ask for it.
Setup then validates the bundle, writes it, and deploys:
```text
@Paseo explain what this project does
.paseo/
├── hub.yml
└── workflows/
└── slack-help.yml
```
Hub starts the agent on your daemon and posts its reply in the Slack thread. Open the project's **Activity** tab if nothing runs.
If `.paseo/` already exists, setup asks before replacing it. Declining the daemon connection prints `paseo hub connect <hub>; then paseo hub init` — both commands, because connecting alone does not create the workflow. Declining only the starter workflow prints `paseo hub init`.
Hub keeps its local state in your user data directory (normally `~/.local/share/paseo-hub`). [Self-hosting](/docs/hub/self-hosting) covers PostgreSQL, public URLs, environment-managed apps, Docker, and cloud deployment. [Configuration](/docs/hub/configuration) explains the generated files and manual deployment.
## 5. Mention the bot
In the channel you invited the bot to:
```text
@Paseo have a look
```
Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal prints the project's Activity URL, where the run appears. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a workflow.
## Next
- [How Hub works](/docs/hub/concepts) — how an event becomes a workflow run on your daemon.
- [Generated starter bundle](/docs/hub/configuration#generated-starter-bundle) — the two files setup wrote, field by field.
- [Workflows](/docs/hub/workflows) — routing, prompts, and provider replies.
- [Hub security](/docs/hub/security) — read this before widening `from_users` or giving an agent GitHub authority.
Hub keeps its local state in your user data directory, normally `~/.local/share/paseo-hub`. [Self-hosting](/docs/hub/self-hosting) covers deployment and advanced configuration when you outgrow the local run.
+7 -1
View File
@@ -34,8 +34,14 @@ steps:
- { type: discord.reply, max: 1, required: true }
```
Turn on Discord Developer Mode to copy IDs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
Discord filters use IDs, not server names, display names, or Hub connection slugs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
The reply posts in the triggering thread or channel. `discord.reply` grants `hub.reply`, but does not rewrite the prompt. A Discord trigger grants no GitHub credential; add a [`github` block](/docs/hub/github) to the step that needs one.
Leading declared inputs follow the mention. Hub exposes the remaining text as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
## Find your Discord IDs
Turn on **Settings → Advanced → Developer Mode**, then right-click to **Copy Server ID** for `guild`, **Copy Channel ID** for `channels`, and **Copy User ID** for `from_users`. All three are numeric snowflakes; quote them so YAML keeps them as strings.
[Guided setup](/docs/hub/quickstart) fills `guild` from the Discord app you connected and asks you for your user ID.
+11 -1
View File
@@ -34,7 +34,7 @@ steps:
- { type: slack.reply, max: 1, required: true }
```
Slack filters use IDs, not display names. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
Slack filters use IDs, not display names or Hub connection slugs. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
The reply posts in the triggering thread. A root message gets a thread; a threaded message stays there. `slack.reply` grants `hub.reply`, but does not add reply instructions to the prompt.
@@ -45,3 +45,13 @@ Leading declared inputs follow the mention:
```
Hub consumes consecutive declared headers and exposes the remainder as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
## Find your Slack IDs
| Filter | Where to copy it |
| ------------ | ------------------------------------------------------------------------------------------- |
| `workspace` | Open Slack in a browser. The team ID is the `T…` segment of the URL. |
| `from_users` | Your avatar → **Profile** → **⋮** → **Copy member ID**. Member IDs start with `U`. |
| `channels` | Channel name → **About**. The channel ID is at the bottom of the panel and starts with `C`. |
[Guided setup](/docs/hub/quickstart) fills `workspace` from the Slack app you connected and asks you for your member ID, so a generated starter already carries both.