Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6089574f | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 |
+5
-1
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
|
|||||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
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
|
— 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
|
## The name
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# gVisor supervisor topology for Linux deployments
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Build the POC around **one root-owned host service that invokes `runsc` directly against OCI bundles**. The service exposes a Paseo-specific Unix socket and nothing else. A host-installed Paseo daemon connects to that socket directly. The official Paseo Docker container receives only a bind mount of that socket and a deployment identifier; the host service maps that identifier to root-owned configuration containing the real host workspace paths.
|
||||||
|
|
||||||
|
Do not put `runsc` inside the Paseo container. Do not give Paseo, the Paseo container, or an agent sandbox `docker.sock`, `containerd.sock`, the runsc state directory, arbitrary OCI configuration, or the supervisor's executable environment.
|
||||||
|
|
||||||
|
This is the smallest topology that has the same sandbox owner in both deployments and does not make Docker or containerd part of Paseo's agent control plane.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Host-installed deployment
|
||||||
|
|
||||||
|
paseo daemon (user) ── narrow RPC ──> /run/paseo-sandbox/supervisor.sock
|
||||||
|
│ root-owned supervisor
|
||||||
|
├─ builds validated OCI bundle
|
||||||
|
├─ runsc --root=/run/paseo-sandbox/runsc ...
|
||||||
|
└─ owns PTY/pipes and lifecycle records
|
||||||
|
|
||||||
|
Official Docker deployment
|
||||||
|
|
||||||
|
paseo container (uid 1000)
|
||||||
|
└─ bind-mounted supervisor.sock ───────> same host supervisor and runsc
|
||||||
|
│
|
||||||
|
host bind source ── supervisor mapping ────┴─> sandbox mount at /workspace
|
||||||
|
|
||||||
|
Agent sandbox
|
||||||
|
├─ versioned read-only rootfs
|
||||||
|
├─ only approved workspace/credential mounts
|
||||||
|
├─ no supervisor socket
|
||||||
|
└─ no Docker/containerd socket
|
||||||
|
```
|
||||||
|
|
||||||
|
## Primary-source findings
|
||||||
|
|
||||||
|
### `runsc` is usable as the low-level boundary
|
||||||
|
|
||||||
|
`runsc` is an OCI runtime. Its direct quick start constructs a bundle containing `config.json` and a `rootfs`, then runs that bundle with `sudo runsc run <id>`.[1] The OCI runtime specification defines durable container identity and the `create`, `start`, `state`, `kill`, and `delete` operations. Returned state includes the container ID, lifecycle status, runtime PID, and absolute bundle path.[2] This is enough for a supervisor to own container lifecycle without Docker or containerd.
|
||||||
|
|
||||||
|
The OCI configuration controls the process user, capabilities, `noNewPrivileges`, read-only root filesystem, additional mounts, namespace set, and resource limits. Bind-mount sources name paths on the runtime host, while mount destinations name paths inside the container.[3] The supervisor must therefore construct `config.json`; a daemon inside Docker cannot safely or correctly submit its own container-visible paths as host mount sources.
|
||||||
|
|
||||||
|
`runsc exec` starts a **new** process in an existing container. Its implementation supports a process JSON file, detached execution, a PID file, and a console socket that transfers the master PTY file descriptor.[4] It is not an API for attaching new stdio to an already-running process. `runsc list` and `runsc state` load containers from the selected runtime root, so a restarted supervisor can reconcile runtime state when it uses the same root directory.[5]
|
||||||
|
|
||||||
|
**Design consequence:** the supervisor, not the Paseo daemon, must retain PTY or pipe ownership and multiplex daemon connections. Daemon reconnect is feasible. A supervisor crash can recover container identity and status, but cannot be assumed to recover the original stdio file descriptors.
|
||||||
|
|
||||||
|
### gVisor narrows, but does not erase, the host boundary
|
||||||
|
|
||||||
|
gVisor's Sentry implements the application-facing system call API rather than passing application system calls directly to the host. Host filesystem access is mediated by a Gofer over a connected socket; the normal Sentry runs in an empty mount namespace and has a restricted host syscall surface.[6] The `runsc` configuration source states that `--directfs` lets the Sentry access the filesystem directly with elevated privileges, whereas the normal mode keeps the Gofer in the data path.[7]
|
||||||
|
|
||||||
|
**POC rule:** use the default Gofer-backed filesystem path and do not enable `--directfs`. A writable workspace still gives the sandbox authority to alter every file mounted there. gVisor protects the host kernel boundary; it does not turn an overly broad mount into a safe mount.
|
||||||
|
|
||||||
|
### Rootless `runsc` does not fit this lifecycle
|
||||||
|
|
||||||
|
gVisor documents three rootless patterns. The convenient `runsc --rootless` mode maps only the caller's UID and explicitly does not support `runsc create`; it also lacks save/restore and Netstack external connectivity, ignores cgroup configuration errors, and requires host networking for external access.[8] The native OCI user-namespace route can use multiple mappings through `newuidmap`/`newgidmap`, but currently lacks network namespacing; strictly unprivileged nested environments collapse to a single UID/GID mapping and cannot faithfully unpack ordinary multi-owner root filesystems.[8]
|
||||||
|
|
||||||
|
The `runsc` source also warns that defense-in-depth is weaker in rootless mode because Sentry and Gofer are root inside a user namespace mapped to the caller.[7]
|
||||||
|
|
||||||
|
**Decision:** retain the required root-owned supervisor for the POC. Rootless mode removes capabilities needed by the intended `create`/`start` lifecycle and produces different behavior between host and Docker deployments.
|
||||||
|
|
||||||
|
### The Unix socket can be a narrow authorization boundary
|
||||||
|
|
||||||
|
On Linux, pathname Unix sockets honor parent-directory permissions; connecting to a stream socket requires write permission on the socket. Ownership and mode can be changed with `chown`/`chmod`. `SO_PEERCRED` returns the credentials captured for the connected peer.[9] Abstract sockets have no meaningful filesystem permissions, so they are inappropriate here.[9]
|
||||||
|
|
||||||
|
Use a pathname socket such as `/run/paseo-sandbox/supervisor.sock`, owned by `root:paseo-sandbox` with mode `0660`, in a root-owned non-writable directory. The supervisor must also verify `SO_PEERCRED`; filesystem mode is not the whole authorization policy. For Docker, Compose must add the socket's numeric host GID as a supplementary group because Unix permission checks use numeric credentials.
|
||||||
|
|
||||||
|
The socket is mounted only into the outer Paseo container. It is never included among OCI mounts generated for an agent. Linux mount namespaces isolate mount views, and network namespaces isolate network resources.[10] A filesystem pathname that is absent from the sandbox mount namespace is not reachable merely because another process on the host can see it.
|
||||||
|
|
||||||
|
### Docker and containerd integration are valid tests, but the wrong control plane
|
||||||
|
|
||||||
|
gVisor's Docker integration registers `runsc` in `/etc/docker/daemon.json`, restarts Docker, and selects it with `docker run --runtime=runsc`.[11] Its containerd integration installs `containerd-shim-runsc-v1`, registers `io.containerd.runsc.v1` in `config.toml`, restarts containerd, and then uses `ctr` or CRI clients that communicate with containerd.[12]
|
||||||
|
|
||||||
|
Those integrations solve image management, network setup, and lifecycle orchestration. They also place the Docker/containerd API between Paseo and `runsc`. Exposing either manager socket would grant a much broader container-management authority than Paseo needs. Even if only the root supervisor held that socket, the POC would inherit daemon configuration, version, restart, plugin, and sibling-container behavior that direct OCI operation avoids.
|
||||||
|
|
||||||
|
Docker bind mounts name a source on the Docker daemon host and a destination in the outer container. They are writable by default and are tied to the host's directory structure.[13] This confirms why the Docker topology needs an explicit host-side mapping: `/workspace` inside Paseo is not a trustworthy host path.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
| Alternative | Host-installed daemon | Official Docker deployment | Privilege and socket consequences | Decision |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Host root supervisor + direct `runsc` OCI** | Direct Unix socket | Bind-mount only the same Unix socket; host config maps deployment roots | One small root service owns bundles, runsc state, namespaces, PTYs, and mounts. No general manager socket. | **Choose for POC** |
|
||||||
|
| Root supervisor driving Docker with `--runtime=runsc` | Requires Docker even for local installs | Creates sibling gVisor containers | Paseo remains off `docker.sock`, but the supervisor holds root-equivalent Docker authority and inherits Docker lifecycle/network semantics. | Keep as a diagnostic fallback, not the POC |
|
||||||
|
| Root supervisor driving containerd's runsc shim | Adds containerd to local installs | Creates sibling containers through containerd | Same broad-manager issue; also adds shim/plugin/CNI configuration and a daemon restart prerequisite. | Reject for POC |
|
||||||
|
| Run `runsc` inside the official Paseo container | Nested only | Same container | Conflicts with the image's non-root daemon, needs substantial namespace/cgroup/device privilege, and rootless paths lack required lifecycle/network behavior. A privileged outer container would enlarge the host attack surface. | Reject |
|
||||||
|
| Daemon invokes rootless `runsc` | Different behavior and limits | Nested rootless constraints | No restricted root supervisor; `create` and useful isolated networking are not available in the simple rootless mode. | Reject |
|
||||||
|
|
||||||
|
## Recommended POC topology
|
||||||
|
|
||||||
|
### Host service and protocol
|
||||||
|
|
||||||
|
Install `paseo-sandbox-supervisor` as a system service running as root. Give it private directories:
|
||||||
|
|
||||||
|
- `/run/paseo-sandbox/` for the socket, PID/lease data, and a single explicit runsc `--root`;
|
||||||
|
- `/var/lib/paseo-sandbox/bundles/<sandbox-id>/` for generated bundles and immutable metadata;
|
||||||
|
- `/var/lib/paseo-sandbox/rootfs/<version>/` for versioned, read-only base root filesystems;
|
||||||
|
- a root-owned deployment registry mapping an opaque deployment ID to allowed host workspace and credential roots.
|
||||||
|
|
||||||
|
The RPC should expose domain operations such as `createSandbox`, `startProcess`, `writeInput`, `subscribeOutput`, `signalProcess`, `inspectSandbox`, and `deleteSandbox`. It must not expose argv for `runsc`, raw OCI JSON, arbitrary mount source/destination pairs, hooks, capabilities, host paths, or generic `exec` as root. Requests identify a registered workspace by opaque handle and a fixed provider launch profile. The supervisor resolves paths beneath configured roots using descriptor-relative operations, rejects symlinks and mount escapes, generates the bundle, and invokes a pinned `runsc` binary with fixed flags.
|
||||||
|
|
||||||
|
The supervisor records the requesting peer identity, deployment ID, Paseo sandbox ID, runsc container ID, bundle version, process status, and output cursor. Agent sandboxes receive no copy of the control socket.
|
||||||
|
|
||||||
|
### Host-installed Paseo
|
||||||
|
|
||||||
|
The installer:
|
||||||
|
|
||||||
|
1. installs signed/pinned `runsc` artifacts and the supervisor;
|
||||||
|
2. creates the supervisor group and adds the daemon user;
|
||||||
|
3. registers allowed workspace roots through an operator-owned command or root-owned config;
|
||||||
|
4. starts the root service;
|
||||||
|
5. leaves the Paseo daemon unprivileged.
|
||||||
|
|
||||||
|
The daemon connects to the pathname socket and refers to host workspaces through registered handles. Arbitrary user-selected directories require an explicit registration policy; allowing every path the daemon user can name would recreate the daemon's current authority inside a root service and invites symlink/mount confusion.
|
||||||
|
|
||||||
|
### Official Docker deployment
|
||||||
|
|
||||||
|
The outer Paseo container remains non-root. Its deployment adds only:
|
||||||
|
|
||||||
|
- a read-write bind mount of `/run/paseo-sandbox/supervisor.sock` at the same path;
|
||||||
|
- the socket's numeric group through Compose `group_add`;
|
||||||
|
- an opaque supervisor deployment ID;
|
||||||
|
- the existing workspace and home bind mounts.
|
||||||
|
|
||||||
|
The host operator registers that deployment ID with mappings such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
container /workspace -> host /srv/paseo/workspace
|
||||||
|
container /home/paseo -> host /srv/paseo/home
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon sends only the deployment ID plus container-relative workspace identity. The supervisor substitutes the preconfigured host source and mounts it at the expected sandbox destination. It rejects client-provided host paths. The outer container gets neither the bundle directory nor runsc state.
|
||||||
|
|
||||||
|
The sandbox rootfs cannot be taken implicitly from the running outer container without involving Docker's API. The POC therefore needs a separately installed versioned rootfs containing Node and one test provider CLI. A later packaging ticket must decide how official and user-derived agent images produce matching sandbox rootfs artifacts without giving Paseo an image-manager socket.
|
||||||
|
|
||||||
|
### Process I/O and reconnect
|
||||||
|
|
||||||
|
For each provider process, the supervisor creates and retains a PTY master (via runsc's console-socket support) or retained pipes, drains output continuously into a bounded sequence buffer, and accepts multiple narrow subscribers. Paseo reconnects with `(sandbox-id, process-id, last-sequence)` and receives missed output before live data.
|
||||||
|
|
||||||
|
On daemon restart, the supervisor remains alive, so the provider process and file descriptors remain owned. On supervisor restart, reconcile with `runsc list/state` and persisted records. Do not claim full reattachment: OCI state recovers lifecycle metadata, while `runsc exec` creates a new process and cannot reacquire an old PTY master. The POC should report such a process as `running-unattached` and either leave it alive for inspection or terminate/recreate it according to explicit policy. A stronger crash-recovery design would need a persistent in-sandbox I/O relay or tested checkpoint/restore and belongs in a later ticket.
|
||||||
|
|
||||||
|
### Networking
|
||||||
|
|
||||||
|
Use two POC profiles:
|
||||||
|
|
||||||
|
1. `--network=none` for filesystem, process, lifecycle, and reconnect tests. The runsc source defines this as loopback-only Netstack.[7]
|
||||||
|
2. `--network=sandbox` for networked tests, with the root supervisor creating a dedicated network namespace, veth, addressing, DNS inputs, and host NAT/firewall rules before start. gVisor describes sandbox networking as its isolated internal stack.[7]
|
||||||
|
|
||||||
|
Do not use `--network=host` as the default. gVisor says host passthrough disables the userspace stack and decreases host isolation.[14] It is acceptable only as a clearly labeled bring-up diagnostic. Agent sandboxes created for a Docker-hosted Paseo daemon are siblings at the host network layer, not members of the outer container's Compose network; reaching Compose-only services requires host-published endpoints or a separately designed bridge.
|
||||||
|
|
||||||
|
## POC prerequisites and test matrix
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Linux 5.6 or newer on `amd64` or `arm64`, matching gVisor's documented support floor.[15]
|
||||||
|
- Pinned `runsc` and required companion binaries installed on the host, not inside Paseo.
|
||||||
|
- A versioned OCI rootfs prepared without a live Docker/containerd socket in the Paseo control path.
|
||||||
|
- A root-owned system service, private state directories, dedicated group, and pathname Unix socket.
|
||||||
|
- For Docker: Linux Docker Engine, host bind-source registration, socket bind mount, and matching supplementary GID.
|
||||||
|
- POC starts with the default systrap platform; KVM and `/dev/kvm` are not prerequisites.
|
||||||
|
|
||||||
|
### Required tests
|
||||||
|
|
||||||
|
Run every applicable case once with a host-installed daemon and once with the official Docker daemon:
|
||||||
|
|
||||||
|
1. create/start a sandbox and prove gVisor is running;
|
||||||
|
2. read/write only the registered workspace; deny adjacent host paths, supervisor state, and the socket;
|
||||||
|
3. reject `..`, symlink, bind-submount, stale-handle, and deployment-ID substitution attempts;
|
||||||
|
4. prove the sandbox has no Docker/containerd/supervisor socket and cannot add mounts or capabilities;
|
||||||
|
5. stream stdin/stdout/stderr, disconnect Paseo, reconnect by output sequence, and continue the same process;
|
||||||
|
6. restart Paseo while work runs and preserve the session;
|
||||||
|
7. restart the supervisor and verify state reconciliation plus the documented unattached-I/O outcome;
|
||||||
|
8. signal, wait, delete, and garbage-collect stopped and half-created containers;
|
||||||
|
9. exercise `network=none`, then sandbox Netstack DNS and outbound HTTPS; verify inbound and host-loopback behavior explicitly;
|
||||||
|
10. verify uid/gid behavior on bind-mounted workspaces for host users other than 1000 and for Docker's default 1000:1000;
|
||||||
|
11. enforce CPU, memory, PID, and output-buffer bounds;
|
||||||
|
12. test both supported architectures in CI or dedicated Linux runners.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
- **Root supervisor:** trusted with host root, runsc lifecycle, configured source roots, network namespaces, and resource controls. Its RPC parser and path resolver are security-critical.
|
||||||
|
- **Paseo daemon:** trusted to request operations within pre-registered deployment/workspace policy, but not trusted with root, runsc flags, OCI hooks, raw host paths, or general container management.
|
||||||
|
- **Outer official Docker container:** same authority as its Paseo daemon plus mounted workspace/home; possessing the narrow socket must not expand it beyond the RPC policy.
|
||||||
|
- **gVisor Sentry/Gofer:** trusted isolation layer. Only explicitly mounted data is available; Gofer-backed mounts remain part of the host-facing trusted computing base.
|
||||||
|
- **Agent process and its output:** untrusted. It receives no control socket and cannot ask the supervisor directly.
|
||||||
|
- **Docker/containerd:** used to run the outer official deployment if the operator chooses Docker, but outside the agent sandbox control path.
|
||||||
|
|
||||||
|
## Open uncertainties
|
||||||
|
|
||||||
|
1. **Supervisor-crash I/O recovery:** confirm runsc's exact behavior when the process holding the PTY master dies. Current APIs support state reconciliation and new exec, not attaching to existing stdio.
|
||||||
|
2. **Rootfs/toolchain distribution:** decide how provider CLIs, native libraries, CA certificates, git/ssh, and user-installed custom agents enter a signed/versioned sandbox rootfs for both deployment modes.
|
||||||
|
3. **Credential mounts:** define per-provider read/write requirements. Mounting all of `/home/paseo` would defeat credential separation.
|
||||||
|
4. **Workspace identity and path safety:** choose the registration UX and prove descriptor-relative resolution across symlinks, nested mounts, renamed roots, and Docker bind-source changes.
|
||||||
|
5. **Networking policy:** choose CNI versus supervisor-owned veth/NAT, DNS handling, IPv6, egress filtering, inbound ports, and access to host or Compose services.
|
||||||
|
6. **Resource controls:** verify cgroup v2 ownership and cleanup under systemd across supported distributions.
|
||||||
|
7. **Provider compatibility:** test PTY versus pipes, subprocess trees, file watching, git credential helpers, browsers, and agents that launch their own sandboxes.
|
||||||
|
8. **Multi-user authorization:** decide whether one supervisor serves multiple Paseo users and how peer credentials partition state and registered roots.
|
||||||
|
|
||||||
|
## Later work unblocked
|
||||||
|
|
||||||
|
This decision provides the topology needed to scope later tickets for:
|
||||||
|
|
||||||
|
- the restricted supervisor RPC and authorization model;
|
||||||
|
- OCI bundle generation, mount policy, and runsc lifecycle adapter;
|
||||||
|
- daemon-side sandbox transport and reconnectable I/O;
|
||||||
|
- host installer/systemd packaging and rootfs distribution;
|
||||||
|
- official Docker Compose/socket/GID/deployment-root wiring;
|
||||||
|
- network namespace and egress policy;
|
||||||
|
- credential and workspace mount profiles;
|
||||||
|
- lifecycle garbage collection and supervisor-restart behavior;
|
||||||
|
- dual-deployment security and compatibility test harnesses.
|
||||||
|
|
||||||
|
It does **not** settle rootfs packaging, production networking, credential partitioning, or supervisor-crash stream recovery.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
1. gVisor, [OCI Quick Start](https://gvisor.dev/docs/user_guide/quick_start/oci/).
|
||||||
|
2. Open Container Initiative, [Runtime Specification: state, lifecycle, and operations](https://github.com/opencontainers/runtime-spec/blob/main/runtime.md).
|
||||||
|
3. Open Container Initiative, [Runtime Configuration Specification](https://github.com/opencontainers/runtime-spec/blob/main/config.md).
|
||||||
|
4. gVisor source, [`runsc/cmd/exec.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/exec.go).
|
||||||
|
5. gVisor source, [`runsc/cmd/list.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/list.go) and [`runsc/cmd/state.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/state.go).
|
||||||
|
6. gVisor, [Security Model](https://gvisor.dev/docs/architecture_guide/security/).
|
||||||
|
7. gVisor source, [`runsc/config/config.go`](https://github.com/google/gvisor/blob/master/runsc/config/config.go).
|
||||||
|
8. gVisor, [Rootless](https://gvisor.dev/docs/user_guide/rootless/).
|
||||||
|
9. Linux man-pages, [`unix(7)`](https://man7.org/linux/man-pages/man7/unix.7.html).
|
||||||
|
10. Linux man-pages, [`namespaces(7)`](https://man7.org/linux/man-pages/man7/namespaces.7.html).
|
||||||
|
11. gVisor, [Docker Quick Start](https://gvisor.dev/docs/user_guide/quick_start/docker/).
|
||||||
|
12. gVisor, [Containerd Quick Start](https://gvisor.dev/docs/user_guide/containerd/quick_start/).
|
||||||
|
13. Docker, [Bind mounts](https://docs.docker.com/engine/storage/bind-mounts/).
|
||||||
|
14. gVisor, [Networking: network passthrough](https://gvisor.dev/docs/user_guide/networking/#network-passthrough).
|
||||||
|
15. gVisor, [Installation](https://gvisor.dev/docs/user_guide/install/).
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
|
sha256-+fewwjlLlJN9VXiBOGvALNQgNxnoVXYQzBDra3ylJhA=
|
||||||
|
|||||||
Generated
+2
-2
@@ -38881,7 +38881,7 @@
|
|||||||
"version": "0.5.0-beta.4",
|
"version": "0.5.0-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@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",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.5.0-beta.4",
|
||||||
@@ -38890,7 +38890,7 @@
|
|||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.5.0-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
|
|||||||
@@ -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);
|
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 ({
|
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||||
page,
|
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);
|
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");
|
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
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 expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
|
|
||||||
await clickFirstChangedLine(page);
|
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 ({
|
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");
|
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||||
const lineHeight = Math.round(fontSize * 1.5);
|
const lineHeight = Math.round(fontSize * 1.5);
|
||||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
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();
|
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.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 }) => {
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
|
|||||||
finalText: string,
|
finalText: string,
|
||||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
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) => {
|
await page.evaluate((tabTestId) => {
|
||||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
|
|||||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
await expect(page.getByText("export const sourceA = true;")).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(
|
const heldTimeline = await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
|
|||||||
|
|
||||||
let workspace: SeededWorkspace;
|
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 () => {
|
test.beforeAll(async () => {
|
||||||
workspace = await seedWorkspace({
|
workspace = await seedWorkspace({
|
||||||
repoPrefix: "file-tree-rail-toggle-",
|
repoPrefix: "file-tree-rail-toggle-",
|
||||||
@@ -21,6 +33,21 @@ test.afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("File panel tree rail", () => {
|
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) => {
|
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await openFileExplorer(page);
|
await openFileExplorer(page);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ test.describe("Tab creation", () => {
|
|||||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
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(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ function emptyState(scope: Locator): Locator {
|
|||||||
return scope.getByTestId("workspace-pane-empty-state");
|
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 {
|
function sidePanelToggle(page: Page): Locator {
|
||||||
return page.getByTestId("workspace-explorer-toggle").first();
|
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 test.step("revealing the Side panel seeds nothing", async () => {
|
||||||
await sidePanelToggle(page).click();
|
await sidePanelToggle(page).click();
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
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 expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
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 });
|
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||||
}
|
}
|
||||||
if (draft.notes !== undefined) {
|
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);
|
const modal = editModal(page);
|
||||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
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)) {
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
if (field === "notes") {
|
if (field === "notes") {
|
||||||
await expect(
|
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
value,
|
||||||
).toHaveValue(value);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
|||||||
export interface MockAgentOptions {
|
export interface MockAgentOptions {
|
||||||
repoPrefix: string;
|
repoPrefix: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||||
port?: number;
|
port?: number;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
|||||||
export async function seedMockAgentWorkspace(
|
export async function seedMockAgentWorkspace(
|
||||||
options: MockAgentOptions,
|
options: MockAgentOptions,
|
||||||
): Promise<MockAgentWorkspace> {
|
): 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 {
|
try {
|
||||||
const agent = await workspace.client.createAgent({
|
const agent = await workspace.client.createAgent({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
|||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube,
|
TestTube,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "./profile-appearance";
|
} from "./profile-appearance";
|
||||||
|
|
||||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
/** 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
|
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||||
|
|||||||
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
|
|||||||
newBrowser: "New browser",
|
newBrowser: "New browser",
|
||||||
splitRight: "Split pane right",
|
splitRight: "Split pane right",
|
||||||
splitDown: "Split pane down",
|
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: {},
|
icons: {},
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||||
|
activeTabKind: null,
|
||||||
|
activeTabIndex: -1,
|
||||||
|
activeTabCount: 0,
|
||||||
dispatch: (action) => dispatched.push(action),
|
dispatch: (action) => dispatched.push(action),
|
||||||
runGitAction: (action) => runGitActions.push(action),
|
runGitAction: (action) => runGitActions.push(action),
|
||||||
},
|
},
|
||||||
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
expect(
|
||||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
contributions
|
||||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
.filter((item) =>
|
||||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
[
|
||||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
"tab:new-agent",
|
||||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
"git:commit",
|
||||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
"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", () => {
|
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
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);
|
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", () => {
|
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();
|
for (const contribution of contributions) contribution.run();
|
||||||
|
|
||||||
expect(fixture.dispatched).toEqual([
|
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||||
{ id: "workspace.agent.new", scope: "workspace" },
|
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||||
{ 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" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps workspace creation commands available outside Git", () => {
|
it("keeps workspace creation commands available outside Git", () => {
|
||||||
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual([
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
"tab:new-agent",
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
"tab:new-terminal",
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
|
||||||
"tab:new-browser",
|
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
|
||||||
"pane:split-right",
|
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||||
"pane:split-down",
|
|
||||||
]);
|
|
||||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { GitAction, GitActions } from "@/git/policy";
|
import type { GitAction, GitActions } from "@/git/policy";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
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 { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||||
|
|
||||||
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
|
|||||||
newBrowser: string;
|
newBrowser: string;
|
||||||
splitRight: string;
|
splitRight: string;
|
||||||
splitDown: 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 {
|
export interface WorkspaceCommandCenterIcons {
|
||||||
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
|
|||||||
newBrowser?: CommandCenterIcon;
|
newBrowser?: CommandCenterIcon;
|
||||||
splitRight?: CommandCenterIcon;
|
splitRight?: CommandCenterIcon;
|
||||||
splitDown?: 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;
|
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
|
|||||||
splitRight?: ShortcutKey[][];
|
splitRight?: ShortcutKey[][];
|
||||||
splitDown?: ShortcutKey[][];
|
splitDown?: ShortcutKey[][];
|
||||||
archiveWorkspace?: ShortcutKey[][];
|
archiveWorkspace?: ShortcutKey[][];
|
||||||
|
previousTab?: ShortcutKey[][];
|
||||||
|
nextTab?: ShortcutKey[][];
|
||||||
|
closeCurrentTab?: ShortcutKey[][];
|
||||||
|
closePane?: ShortcutKey[][];
|
||||||
|
togglePaneMaximization?: ShortcutKey[][];
|
||||||
|
toggleFocusMode?: ShortcutKey[][];
|
||||||
|
toggleSidePanel?: ShortcutKey[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterSource {
|
export interface WorkspaceCommandCenterSource {
|
||||||
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: boolean;
|
canSplitPanes: boolean;
|
||||||
canOpenBrowserTabs: boolean;
|
canOpenBrowserTabs: boolean;
|
||||||
|
isGit: boolean;
|
||||||
};
|
};
|
||||||
|
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||||
|
activeTabIndex: number;
|
||||||
|
activeTabCount: number;
|
||||||
dispatch(action: KeyboardActionDefinition): void;
|
dispatch(action: KeyboardActionDefinition): void;
|
||||||
runGitAction(action: GitAction): 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,
|
source: WorkspaceCommandCenterSource,
|
||||||
): CommandCenterContribution[] {
|
): CommandCenterContribution[] {
|
||||||
const contributions: CommandCenterContribution[] = [
|
const contributions: CommandCenterContribution[] = [
|
||||||
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
const primary = source.gitActions.primary;
|
const primary = source.gitActions.primary;
|
||||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-terminal",
|
id: "tab:new-terminal",
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: source.labels.newTerminal,
|
title: source.labels.newTerminal,
|
||||||
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
icon: source.icons.newTerminal,
|
icon: source.icons.newTerminal,
|
||||||
shortcutKeys: source.shortcuts.newTerminal,
|
shortcutKeys: source.shortcuts.newTerminal,
|
||||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (source.capabilities.canOpenBrowserTabs) {
|
if (source.capabilities.canOpenBrowserTabs) {
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-browser",
|
id: "tab:new-browser",
|
||||||
rank: 3,
|
rank: 3,
|
||||||
title: source.labels.newBrowser,
|
title: source.labels.newBrowser,
|
||||||
keywords: ["browser", "web", "preview"],
|
keywords: ["browser", "web", "preview"],
|
||||||
icon: source.icons.newBrowser,
|
icon: source.icons.newBrowser,
|
||||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source.capabilities.canSplitPanes) {
|
return contributions;
|
||||||
contributions.push(
|
}
|
||||||
buildWorkspaceAction({
|
|
||||||
source,
|
export function buildWorkspaceCommandCenterContributions(
|
||||||
id: "pane:split-right",
|
source: WorkspaceCommandCenterSource,
|
||||||
rank: 4,
|
): CommandCenterContribution[] {
|
||||||
title: source.labels.splitRight,
|
const contributions = [
|
||||||
keywords: ["split", "pane", "vertical"],
|
...buildCreationContributions(source),
|
||||||
icon: source.icons.splitRight,
|
...buildPanelContributions(source),
|
||||||
shortcutKeys: source.shortcuts.splitRight,
|
...buildActiveTabContributions(source),
|
||||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||||
visibility: "query",
|
buildQueryAction(source, {
|
||||||
}),
|
id: "side-panel:toggle",
|
||||||
buildWorkspaceAction({
|
rank: 70,
|
||||||
source,
|
title: source.labels.toggleSidePanel,
|
||||||
id: "pane:split-down",
|
keywords: ["side", "panel", "toggle", "show", "hide"],
|
||||||
rank: 5,
|
icon: source.icons.sidePanel,
|
||||||
title: source.labels.splitDown,
|
shortcutKeys: source.shortcuts.toggleSidePanel,
|
||||||
keywords: ["split", "pane", "horizontal"],
|
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||||
icon: source.icons.splitDown,
|
}),
|
||||||
shortcutKeys: source.shortcuts.splitDown,
|
];
|
||||||
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
const primary = source.gitActions.primary;
|
||||||
visibility: "query",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||||
if (action.id === primary?.id) continue;
|
if (action.id === primary?.id) continue;
|
||||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||||
}
|
}
|
||||||
return contributions;
|
return contributions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { getIsElectron } from "@/constants/platform";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
@@ -13,6 +33,12 @@ import {
|
|||||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
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 { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getCommandCenterIcon } from "./icon";
|
import { getCommandCenterIcon } from "./icon";
|
||||||
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
|||||||
newBrowser: getCommandCenterIcon(Globe),
|
newBrowser: getCommandCenterIcon(Globe),
|
||||||
splitRight: getCommandCenterIcon(Columns2),
|
splitRight: getCommandCenterIcon(Columns2),
|
||||||
splitDown: getCommandCenterIcon(Rows2),
|
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 {
|
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
function StaticIcon() {
|
function StaticIcon() {
|
||||||
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
|
|||||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||||
archiveWorkspace:
|
archiveWorkspace:
|
||||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
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 selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? 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 cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const { gitActions } = useGitActions({
|
const { gitActions, isGit } = useGitActions({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
cwd: cwd ?? "",
|
cwd: cwd ?? "",
|
||||||
icons: GIT_ACTION_ICONS,
|
icons: GIT_ACTION_ICONS,
|
||||||
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
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: {
|
icons: {
|
||||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||||
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||||
canOpenBrowserTabs: getIsElectron(),
|
canOpenBrowserTabs: getIsElectron(),
|
||||||
|
isGit,
|
||||||
},
|
},
|
||||||
|
activeTabKind,
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabCount: focusedTabs.length,
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
keyboardActionDispatcher.dispatch(action);
|
keyboardActionDispatcher.dispatch(action);
|
||||||
},
|
},
|
||||||
runGitAction,
|
runGitAction,
|
||||||
}),
|
}),
|
||||||
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
|
[
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabKind,
|
||||||
|
focusedTabs.length,
|
||||||
|
gitActions,
|
||||||
|
isCompact,
|
||||||
|
isGit,
|
||||||
|
keyboardActionDispatcher,
|
||||||
|
overrides,
|
||||||
|
runGitAction,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useCommandCenterActions({
|
useCommandCenterActions({
|
||||||
|
|||||||
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
},
|
||||||
desktopSplit: {
|
desktopSplit: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ interface SplitContainerProps {
|
|||||||
renderPaneEmptyState?: (input: {
|
renderPaneEmptyState?: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) => ReactNode;
|
}) => ReactNode;
|
||||||
focusModeEnabled?: boolean;
|
focusModeEnabled?: boolean;
|
||||||
@@ -1289,6 +1290,7 @@ function SplitPaneView({
|
|||||||
: (renderPaneEmptyState?.({
|
: (renderPaneEmptyState?.({
|
||||||
paneId,
|
paneId,
|
||||||
paneIsolated,
|
paneIsolated,
|
||||||
|
isFocused: isFocused && isWorkspaceFocused,
|
||||||
onClosePane: handleClosePane,
|
onClosePane: handleClosePane,
|
||||||
}) ?? null)}
|
}) ?? null)}
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
|
|||||||
@@ -86,23 +86,28 @@ describe("control geometry", () => {
|
|||||||
expect(geometry.formTextInputMd.lineHeight).toBe(20);
|
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 geometry = createControlGeometry(theme);
|
||||||
|
const borderWidth = theme.borderWidth[1];
|
||||||
|
|
||||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||||
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
|
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
|
||||||
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
|
expect(
|
||||||
geometry.fieldControlSm.minHeight,
|
geometry.fieldTextSm.lineHeight +
|
||||||
);
|
geometry.fieldControlSm.paddingVertical * 2 +
|
||||||
|
borderWidth * 2,
|
||||||
|
).toBe(geometry.fieldControlSm.minHeight);
|
||||||
|
|
||||||
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
||||||
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
|
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
|
||||||
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
|
expect(
|
||||||
geometry.fieldControlMd.minHeight,
|
geometry.fieldTextMd.lineHeight +
|
||||||
);
|
geometry.fieldControlMd.paddingVertical * 2 +
|
||||||
|
borderWidth * 2,
|
||||||
|
).toBe(geometry.fieldControlMd.minHeight);
|
||||||
|
|
||||||
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
|
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
|
||||||
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
|
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
|
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);
|
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
|
function fieldVerticalPadding(
|
||||||
return (controlHeight - lineHeight) / 2;
|
controlHeight: number,
|
||||||
|
lineHeight: number,
|
||||||
|
borderWidth: number,
|
||||||
|
): number {
|
||||||
|
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getControlInteractionPhase(
|
export function getControlInteractionPhase(
|
||||||
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createControlGeometry(theme: Theme) {
|
export function createControlGeometry(theme: Theme) {
|
||||||
|
const controlBorderWidth = theme.borderWidth[1];
|
||||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||||
const fieldControlSm = {
|
const fieldControlSm = {
|
||||||
minHeight: CONTROL_HEIGHTS.compact,
|
minHeight: CONTROL_HEIGHTS.compact,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
|
paddingVertical: fieldVerticalPadding(
|
||||||
|
CONTROL_HEIGHTS.compact,
|
||||||
|
fieldTextSmLineHeight,
|
||||||
|
controlBorderWidth,
|
||||||
|
),
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
};
|
};
|
||||||
const fieldControlMd = {
|
const fieldControlMd = {
|
||||||
minHeight: CONTROL_HEIGHTS.field,
|
minHeight: CONTROL_HEIGHTS.field,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
|
paddingVertical: fieldVerticalPadding(
|
||||||
|
CONTROL_HEIGHTS.field,
|
||||||
|
fieldTextMdLineHeight,
|
||||||
|
controlBorderWidth,
|
||||||
|
),
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
};
|
};
|
||||||
const fieldTextSm = {
|
const fieldTextSm = {
|
||||||
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fieldTextSm,
|
fieldTextSm,
|
||||||
fieldTextMd,
|
fieldTextMd,
|
||||||
controlRest: {
|
controlRest: {
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: controlBorderWidth,
|
||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
outlineWidth: 0,
|
outlineWidth: 0,
|
||||||
outlineColor: "transparent",
|
outlineColor: "transparent",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
|
|||||||
}
|
}
|
||||||
if (hint) {
|
if (hint) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
|
<Text style={styles.hintText} testID={hintTestID}>
|
||||||
{hint}
|
{hint}
|
||||||
</Text>
|
</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;
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { useComposerHeightMirror } from "./height-mirror";
|
import { useComposerHeightMirror } from "./height-mirror";
|
||||||
|
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
|
||||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||||
import {
|
import {
|
||||||
@@ -990,20 +991,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
|
|||||||
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
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 {
|
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||||
}
|
}
|
||||||
@@ -1764,7 +1751,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
() => [
|
() => [
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
mode.isMonospace && styles.textInputMonospace,
|
mode.isMonospace && styles.textInputMonospace,
|
||||||
computeTextInputHeightStyle(inputHeight, maxInputHeight),
|
resolveComposerInputHeightStyle({
|
||||||
|
inputHeight,
|
||||||
|
minInputHeight: MIN_INPUT_HEIGHT,
|
||||||
|
maxInputHeight,
|
||||||
|
applyMeasuredHeight: isWeb,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
[inputHeight, maxInputHeight, mode.isMonospace],
|
[inputHeight, maxInputHeight, mode.isMonospace],
|
||||||
);
|
);
|
||||||
@@ -1838,7 +1830,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
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));
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { LiveFileModel, type LiveFileSession } from "./model";
|
import { LiveFileModel, type LiveFileSession } from "./model";
|
||||||
|
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export function useLiveFile(input: {
|
export function useLiveFile(input: {
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
|
|||||||
return client.subscribeFile(target, onVersion);
|
return client.subscribeFile(target, onVersion);
|
||||||
},
|
},
|
||||||
read(target) {
|
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]);
|
}, [input.client]);
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
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 { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { getFileNameFromPath } from "@/attachments/utils";
|
import { getFileNameFromPath } from "@/attachments/utils";
|
||||||
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
|||||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||||
import { createFileObservationSource } from "./editor/observation-source";
|
import { createFileObservationSource } from "./editor/observation-source";
|
||||||
import { FileEditorView } from "./editor/view";
|
import { FileEditorView } from "./editor/view";
|
||||||
|
import { FileSourceView } from "./source/view";
|
||||||
import type { FileConflictAlertState } from "./conflict-alert";
|
import type { FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { LiveFileModel } from "./live-file/model";
|
import type { LiveFileModel } from "./live-file/model";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CodeLineProps {
|
|
||||||
tokens: HighlightToken[];
|
|
||||||
lineNumber: number;
|
|
||||||
gutterWidth: number;
|
|
||||||
highlighted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
mode?: "preview" | "source";
|
mode?: "preview" | "source";
|
||||||
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileLineSelection {
|
|
||||||
lineStart: number;
|
|
||||||
lineEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
return `${size} B`;
|
||||||
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampLineSelection(input: {
|
function ReadonlySource({
|
||||||
lineStart?: number;
|
preview,
|
||||||
lineEnd?: number;
|
filename,
|
||||||
lineCount: number;
|
location,
|
||||||
}): FileLineSelection | null {
|
navigationRevision,
|
||||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
}: {
|
||||||
return null;
|
preview: ExplorerFile;
|
||||||
}
|
filename: string;
|
||||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
location: WorkspaceFileLocation;
|
||||||
const rawLineEnd =
|
navigationRevision: number;
|
||||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
}) {
|
||||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
const theme = UnistylesRuntime.getTheme();
|
||||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
const { t } = useTranslation();
|
||||||
}
|
const visualTheme = useMemo(
|
||||||
|
() => ({
|
||||||
const CodeLine = React.memo(function CodeLine({
|
colorScheme: theme.colorScheme,
|
||||||
tokens,
|
background: theme.colors.surface0,
|
||||||
lineNumber,
|
foreground: theme.colors.foreground,
|
||||||
gutterWidth,
|
cursor: theme.colors.terminal.cursor,
|
||||||
highlighted,
|
foregroundMuted: theme.colors.foregroundMuted,
|
||||||
}: CodeLineProps) {
|
border: theme.colors.border,
|
||||||
const gutterStyle = useMemo(
|
selection: theme.colors.terminal.selectionBackground,
|
||||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
monoFont: theme.fontFamily.mono,
|
||||||
[gutterWidth],
|
codeFontSize: theme.fontSize.code,
|
||||||
);
|
syntax: theme.colors.syntax,
|
||||||
const lineStyle = useMemo(
|
}),
|
||||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
[theme],
|
||||||
[highlighted],
|
|
||||||
);
|
|
||||||
const keyedTokens = useMemo(
|
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
||||||
[tokens],
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={lineStyle}>
|
<FileSourceView
|
||||||
<View style={gutterStyle}>
|
content={preview.content ?? ""}
|
||||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
filename={filename}
|
||||||
{String(lineNumber)}
|
location={location}
|
||||||
</Text>
|
navigationRevision={navigationRevision}
|
||||||
</View>
|
size={preview.size}
|
||||||
<Text selectable style={codeLineStyles.lineText}>
|
theme={visualTheme}
|
||||||
{keyedTokens.map(({ key, token }) => (
|
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||||
<CodeLineToken key={key} token={token} />
|
/>
|
||||||
))}
|
);
|
||||||
</Text>
|
}
|
||||||
|
|
||||||
|
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>
|
</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({
|
function FilePreviewBody({
|
||||||
preview,
|
preview,
|
||||||
mode,
|
mode,
|
||||||
isLoading,
|
isLoading,
|
||||||
isMobile,
|
isMobile: _isMobile,
|
||||||
location,
|
location,
|
||||||
navigationRevision,
|
navigationRevision,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const theme = UnistylesRuntime.getTheme();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
// A line target means the caller wants to land on that line, so fall back to
|
// 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 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(
|
const imageSource = useMemo(
|
||||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||||
[imagePreviewUri],
|
[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) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState} testID="file-preview-loading">
|
<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 (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<ReadonlySource
|
||||||
<RNScrollView
|
preview={preview}
|
||||||
ref={previewScrollRef}
|
filename={filePath}
|
||||||
style={styles.previewContent}
|
location={location}
|
||||||
showsVerticalScrollIndicator
|
navigationRevision={navigationRevision}
|
||||||
>
|
/>
|
||||||
{isMobile ? (
|
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
||||||
) : (
|
|
||||||
<RNScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
|
||||||
>
|
|
||||||
{codeLines}
|
|
||||||
</RNScrollView>
|
|
||||||
)}
|
|
||||||
</RNScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +431,13 @@ function FilePanePresentation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
|
if (errorMessage === "File is too large to display") {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<TooLargeSource />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
<View style={styles.centerState}>
|
<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();
|
schedulePaint();
|
||||||
return;
|
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;
|
selectionRef.current = null;
|
||||||
reviewActions.onStartComment(hit.target);
|
reviewActions.onStartComment(hit.target);
|
||||||
schedulePaint();
|
schedulePaint();
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
|
|||||||
workspaces: "مساحات العمل",
|
workspaces: "مساحات العمل",
|
||||||
agents: "الوكلاء",
|
agents: "الوكلاء",
|
||||||
newAgent: "وكيل جديد",
|
newAgent: "وكيل جديد",
|
||||||
|
open: "فتح {{name}}",
|
||||||
|
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
|
||||||
|
openInFocusedPane: "فتح {{name}} في الجزء النشط",
|
||||||
addProject: "إضافة مشروع",
|
addProject: "إضافة مشروع",
|
||||||
home: "بيت",
|
home: "بيت",
|
||||||
groupByProject: "تجميع حسب المشروع",
|
groupByProject: "تجميع حسب المشروع",
|
||||||
@@ -1700,6 +1703,7 @@ export const ar: TranslationResources = {
|
|||||||
loading: "جارٍ تحميل الملف...",
|
loading: "جارٍ تحميل الملف...",
|
||||||
noPreview: "لا تتوفر معاينة",
|
noPreview: "لا تتوفر معاينة",
|
||||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||||
|
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||||
failedToLoad: "فشل تحميل الملف",
|
failedToLoad: "فشل تحميل الملف",
|
||||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1728,6 +1732,7 @@ export const ar: TranslationResources = {
|
|||||||
label: "الملفات",
|
label: "الملفات",
|
||||||
subtitle: "ملفات مساحة العمل",
|
subtitle: "ملفات مساحة العمل",
|
||||||
tooltip: "تصفح ملفات مساحة العمل",
|
tooltip: "تصفح ملفات مساحة العمل",
|
||||||
|
chooseFile: "اختر ملفًا",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "طلب السحب",
|
label: "طلب السحب",
|
||||||
@@ -2342,9 +2347,9 @@ export const ar: TranslationResources = {
|
|||||||
featuresLabel: "السمات",
|
featuresLabel: "السمات",
|
||||||
featureCount: "{{count}} سمة",
|
featureCount: "{{count}} سمة",
|
||||||
featureCountOne: "{{count}} سمة",
|
featureCountOne: "{{count}} سمة",
|
||||||
notesLabel: "ملاحظات للوكلاء",
|
notesLabel: "متى يُستخدم",
|
||||||
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
|
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
|
||||||
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.',
|
notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
|
||||||
save: "حفظ",
|
save: "حفظ",
|
||||||
saving: "جارٍ الحفظ...",
|
saving: "جارٍ الحفظ...",
|
||||||
remove: "إزالة",
|
remove: "إزالة",
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const en = {
|
|||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "New agent",
|
newAgent: "New agent",
|
||||||
|
open: "Open {{name}}",
|
||||||
|
openInSidePanel: "Open {{name}} in Side panel",
|
||||||
|
openInFocusedPane: "Open {{name}} in focused pane",
|
||||||
addProject: "Add project",
|
addProject: "Add project",
|
||||||
home: "Home",
|
home: "Home",
|
||||||
groupByProject: "Group by project",
|
groupByProject: "Group by project",
|
||||||
@@ -1710,6 +1713,7 @@ export const en = {
|
|||||||
loading: "Loading file...",
|
loading: "Loading file...",
|
||||||
noPreview: "No preview available",
|
noPreview: "No preview available",
|
||||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||||
|
tooLargeToDisplay: "This file is too large to display",
|
||||||
failedToLoad: "Failed to load file",
|
failedToLoad: "Failed to load file",
|
||||||
failedToLoadPreview: "Failed to load file preview",
|
failedToLoadPreview: "Failed to load file preview",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1738,6 +1742,7 @@ export const en = {
|
|||||||
label: "Files",
|
label: "Files",
|
||||||
subtitle: "Workspace files",
|
subtitle: "Workspace files",
|
||||||
tooltip: "Browse workspace files",
|
tooltip: "Browse workspace files",
|
||||||
|
chooseFile: "Choose a file",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Pull request",
|
label: "Pull request",
|
||||||
@@ -2409,10 +2414,9 @@ export const en = {
|
|||||||
featuresLabel: "Features",
|
featuresLabel: "Features",
|
||||||
featureCount: "{{count}} features",
|
featureCount: "{{count}} features",
|
||||||
featureCountOne: "{{count}} feature",
|
featureCountOne: "{{count}} feature",
|
||||||
notesLabel: "Notes for agents",
|
notesLabel: "When to use",
|
||||||
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
|
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
|
||||||
notesHint:
|
notesHint: "Helps agents choose this profile when starting another agent.",
|
||||||
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
|
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saving: "Saving...",
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
|
|||||||
workspaces: "Espacios de trabajo",
|
workspaces: "Espacios de trabajo",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Nuevo agente",
|
newAgent: "Nuevo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} en el panel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
|
||||||
addProject: "Agregar proyecto",
|
addProject: "Agregar proyecto",
|
||||||
home: "Hogar",
|
home: "Hogar",
|
||||||
groupByProject: "Agrupar por proyecto",
|
groupByProject: "Agrupar por proyecto",
|
||||||
@@ -1746,6 +1749,7 @@ export const es: TranslationResources = {
|
|||||||
loading: "Cargando archivo...",
|
loading: "Cargando archivo...",
|
||||||
noPreview: "No hay vista previa disponible",
|
noPreview: "No hay vista previa disponible",
|
||||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||||
|
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
|
||||||
failedToLoad: "No se pudo cargar el archivo",
|
failedToLoad: "No se pudo cargar el archivo",
|
||||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1774,6 +1778,7 @@ export const es: TranslationResources = {
|
|||||||
label: "Archivos",
|
label: "Archivos",
|
||||||
subtitle: "Archivos del espacio de trabajo",
|
subtitle: "Archivos del espacio de trabajo",
|
||||||
tooltip: "Explorar archivos del espacio de trabajo",
|
tooltip: "Explorar archivos del espacio de trabajo",
|
||||||
|
chooseFile: "Elige un archivo",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Solicitud de extracción",
|
label: "Solicitud de extracción",
|
||||||
@@ -2396,10 +2401,9 @@ export const es: TranslationResources = {
|
|||||||
featuresLabel: "Características",
|
featuresLabel: "Características",
|
||||||
featureCount: "{{count}} características",
|
featureCount: "{{count}} características",
|
||||||
featureCountOne: "{{count}} característica",
|
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.",
|
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
|
||||||
notesHint:
|
notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
|
||||||
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
|
|
||||||
save: "Guardar",
|
save: "Guardar",
|
||||||
saving: "Guardando...",
|
saving: "Guardando...",
|
||||||
remove: "Eliminar",
|
remove: "Eliminar",
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
|
|||||||
workspaces: "Espaces de travail",
|
workspaces: "Espaces de travail",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "Nouvel agent",
|
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",
|
addProject: "Ajouter un projet",
|
||||||
home: "Maison",
|
home: "Maison",
|
||||||
groupByProject: "Grouper par projet",
|
groupByProject: "Grouper par projet",
|
||||||
@@ -1750,6 +1753,7 @@ export const fr: TranslationResources = {
|
|||||||
loading: "Chargement du fichier...",
|
loading: "Chargement du fichier...",
|
||||||
noPreview: "Aucun aperçu disponible",
|
noPreview: "Aucun aperçu disponible",
|
||||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||||
|
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
|
||||||
failedToLoad: "Échec du chargement du fichier",
|
failedToLoad: "Échec du chargement du fichier",
|
||||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1778,6 +1782,7 @@ export const fr: TranslationResources = {
|
|||||||
label: "Fichiers",
|
label: "Fichiers",
|
||||||
subtitle: "Fichiers de l’espace de travail",
|
subtitle: "Fichiers de l’espace de travail",
|
||||||
tooltip: "Parcourir les fichiers de l’espace de travail",
|
tooltip: "Parcourir les fichiers de l’espace de travail",
|
||||||
|
chooseFile: "Choisissez un fichier",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Demande de fusion",
|
label: "Demande de fusion",
|
||||||
@@ -2403,11 +2408,10 @@ export const fr: TranslationResources = {
|
|||||||
featuresLabel: "Caractéristiques",
|
featuresLabel: "Caractéristiques",
|
||||||
featureCount: "{{count}} caractéristiques",
|
featureCount: "{{count}} caractéristiques",
|
||||||
featureCountOne: "{{count}} caractéristique",
|
featureCountOne: "{{count}} caractéristique",
|
||||||
notesLabel: "Notes pour les agents",
|
notesLabel: "Quand l’utiliser",
|
||||||
notesPlaceholder:
|
notesPlaceholder:
|
||||||
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
|
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
|
||||||
notesHint:
|
notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
|
||||||
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
|
|
||||||
save: "Enregistrer",
|
save: "Enregistrer",
|
||||||
saving: "Enregistrement...",
|
saving: "Enregistrement...",
|
||||||
remove: "Supprimer",
|
remove: "Supprimer",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
|
|||||||
workspaces: "ワークスペース",
|
workspaces: "ワークスペース",
|
||||||
agents: "エージェント",
|
agents: "エージェント",
|
||||||
newAgent: "新しいエージェント",
|
newAgent: "新しいエージェント",
|
||||||
|
open: "{{name}}を開く",
|
||||||
|
openInSidePanel: "{{name}}をサイドパネルで開く",
|
||||||
|
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
|
||||||
addProject: "プロジェクトを追加",
|
addProject: "プロジェクトを追加",
|
||||||
home: "ホーム",
|
home: "ホーム",
|
||||||
groupByProject: "プロジェクトでグループ化",
|
groupByProject: "プロジェクトでグループ化",
|
||||||
@@ -1717,6 +1720,7 @@ export const ja: TranslationResources = {
|
|||||||
loading: "ファイルを読み込み中...",
|
loading: "ファイルを読み込み中...",
|
||||||
noPreview: "プレビューが利用できません",
|
noPreview: "プレビューが利用できません",
|
||||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||||
|
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1745,6 +1749,7 @@ export const ja: TranslationResources = {
|
|||||||
label: "ファイル",
|
label: "ファイル",
|
||||||
subtitle: "ワークスペースのファイル",
|
subtitle: "ワークスペースのファイル",
|
||||||
tooltip: "ワークスペースのファイルを参照",
|
tooltip: "ワークスペースのファイルを参照",
|
||||||
|
chooseFile: "ファイルを選択",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "プルリクエスト",
|
label: "プルリクエスト",
|
||||||
@@ -2365,10 +2370,9 @@ export const ja: TranslationResources = {
|
|||||||
featuresLabel: "機能",
|
featuresLabel: "機能",
|
||||||
featureCount: "{{count}}個の機能",
|
featureCount: "{{count}}個の機能",
|
||||||
featureCountOne: "{{count}}個の機能",
|
featureCountOne: "{{count}}個の機能",
|
||||||
notesLabel: "エージェントへのメモ",
|
notesLabel: "使用する場面",
|
||||||
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
|
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
|
||||||
notesHint:
|
notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
|
||||||
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
|
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中...",
|
saving: "保存中...",
|
||||||
remove: "削除",
|
remove: "削除",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
|
|||||||
workspaces: "워크스페이스",
|
workspaces: "워크스페이스",
|
||||||
agents: "에이전트",
|
agents: "에이전트",
|
||||||
newAgent: "새 에이전트",
|
newAgent: "새 에이전트",
|
||||||
|
open: "{{name}} 열기",
|
||||||
|
openInSidePanel: "사이드 패널에서 {{name}} 열기",
|
||||||
|
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
|
||||||
addProject: "프로젝트 추가",
|
addProject: "프로젝트 추가",
|
||||||
home: "홈",
|
home: "홈",
|
||||||
groupByProject: "프로젝트별 그룹화",
|
groupByProject: "프로젝트별 그룹화",
|
||||||
@@ -1710,6 +1713,7 @@ export const ko: TranslationResources = {
|
|||||||
loading: "파일 불러오는 중...",
|
loading: "파일 불러오는 중...",
|
||||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||||
|
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||||
failedToLoad: "파일을 불러오지 못했습니다",
|
failedToLoad: "파일을 불러오지 못했습니다",
|
||||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1738,6 +1742,7 @@ export const ko: TranslationResources = {
|
|||||||
label: "파일",
|
label: "파일",
|
||||||
subtitle: "워크스페이스 파일",
|
subtitle: "워크스페이스 파일",
|
||||||
tooltip: "워크스페이스 파일 탐색",
|
tooltip: "워크스페이스 파일 탐색",
|
||||||
|
chooseFile: "파일 선택",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "풀 리퀘스트",
|
label: "풀 리퀘스트",
|
||||||
@@ -2354,10 +2359,9 @@ export const ko: TranslationResources = {
|
|||||||
featuresLabel: "기능",
|
featuresLabel: "기능",
|
||||||
featureCount: "기능 {{count}}개",
|
featureCount: "기능 {{count}}개",
|
||||||
featureCountOne: "기능 {{count}}개",
|
featureCountOne: "기능 {{count}}개",
|
||||||
notesLabel: "에이전트를 위한 메모",
|
notesLabel: "사용 시점",
|
||||||
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
|
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
|
||||||
notesHint:
|
notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
|
||||||
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
|
|
||||||
save: "저장",
|
save: "저장",
|
||||||
saving: "저장하는 중...",
|
saving: "저장하는 중...",
|
||||||
remove: "제거",
|
remove: "제거",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
|
|||||||
workspaces: "Espaços de trabalho",
|
workspaces: "Espaços de trabalho",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Novo agente",
|
newAgent: "Novo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} no painel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} no painel em foco",
|
||||||
addProject: "Adicionar projeto",
|
addProject: "Adicionar projeto",
|
||||||
home: "Início",
|
home: "Início",
|
||||||
groupByProject: "Agrupar por projeto",
|
groupByProject: "Agrupar por projeto",
|
||||||
@@ -1732,6 +1735,7 @@ export const ptBR: TranslationResources = {
|
|||||||
loading: "Carregando arquivo...",
|
loading: "Carregando arquivo...",
|
||||||
noPreview: "Nenhuma prévia disponível",
|
noPreview: "Nenhuma prévia disponível",
|
||||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||||
|
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
|
||||||
failedToLoad: "Falha ao carregar arquivo",
|
failedToLoad: "Falha ao carregar arquivo",
|
||||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1760,6 +1764,7 @@ export const ptBR: TranslationResources = {
|
|||||||
label: "Arquivos",
|
label: "Arquivos",
|
||||||
subtitle: "Arquivos do espaço de trabalho",
|
subtitle: "Arquivos do espaço de trabalho",
|
||||||
tooltip: "Explorar arquivos do espaço de trabalho",
|
tooltip: "Explorar arquivos do espaço de trabalho",
|
||||||
|
chooseFile: "Escolha um arquivo",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Pull request",
|
label: "Pull request",
|
||||||
@@ -2381,10 +2386,9 @@ export const ptBR: TranslationResources = {
|
|||||||
featuresLabel: "Recursos",
|
featuresLabel: "Recursos",
|
||||||
featureCount: "{{count}} recursos",
|
featureCount: "{{count}} recursos",
|
||||||
featureCountOne: "{{count}} recurso",
|
featureCountOne: "{{count}} recurso",
|
||||||
notesLabel: "Notas para os agentes",
|
notesLabel: "Quando usar",
|
||||||
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
|
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
|
||||||
notesHint:
|
notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.",
|
||||||
'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.',
|
|
||||||
save: "Salvar",
|
save: "Salvar",
|
||||||
saving: "Salvando...",
|
saving: "Salvando...",
|
||||||
remove: "Remover",
|
remove: "Remover",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
|
|||||||
workspaces: "Рабочие пространства",
|
workspaces: "Рабочие пространства",
|
||||||
agents: "Агенты",
|
agents: "Агенты",
|
||||||
newAgent: "Новый агент",
|
newAgent: "Новый агент",
|
||||||
|
open: "Открыть {{name}}",
|
||||||
|
openInSidePanel: "Открыть {{name}} в боковой панели",
|
||||||
|
openInFocusedPane: "Открыть {{name}} в активной области",
|
||||||
addProject: "Добавить проект",
|
addProject: "Добавить проект",
|
||||||
home: "Главная",
|
home: "Главная",
|
||||||
groupByProject: "Группировать по проекту",
|
groupByProject: "Группировать по проекту",
|
||||||
@@ -1730,6 +1733,7 @@ export const ru: TranslationResources = {
|
|||||||
loading: "Загрузка файла...",
|
loading: "Загрузка файла...",
|
||||||
noPreview: "Предварительный просмотр недоступен",
|
noPreview: "Предварительный просмотр недоступен",
|
||||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||||
|
tooLargeToDisplay: "Этот файл слишком велик для отображения",
|
||||||
failedToLoad: "Не удалось загрузить файл",
|
failedToLoad: "Не удалось загрузить файл",
|
||||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1759,6 +1763,7 @@ export const ru: TranslationResources = {
|
|||||||
label: "Файлы",
|
label: "Файлы",
|
||||||
subtitle: "Файлы рабочего пространства",
|
subtitle: "Файлы рабочего пространства",
|
||||||
tooltip: "Просмотр файлов рабочего пространства",
|
tooltip: "Просмотр файлов рабочего пространства",
|
||||||
|
chooseFile: "Выберите файл",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "PR",
|
label: "PR",
|
||||||
@@ -2386,10 +2391,9 @@ export const ru: TranslationResources = {
|
|||||||
featuresLabel: "Функции",
|
featuresLabel: "Функции",
|
||||||
featureCount: "Функции: {{count}}",
|
featureCount: "Функции: {{count}}",
|
||||||
featureCountOne: "{{count}} функция",
|
featureCountOne: "{{count}} функция",
|
||||||
notesLabel: "Заметки для агентов",
|
notesLabel: "Когда использовать",
|
||||||
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
|
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
|
||||||
notesHint:
|
notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.",
|
||||||
"MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.",
|
|
||||||
save: "Сохранить",
|
save: "Сохранить",
|
||||||
saving: "Сохранение...",
|
saving: "Сохранение...",
|
||||||
remove: "Удалить",
|
remove: "Удалить",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
|
|||||||
workspaces: "工作区",
|
workspaces: "工作区",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "新建 Agent",
|
newAgent: "新建 Agent",
|
||||||
|
open: "打开{{name}}",
|
||||||
|
openInSidePanel: "在侧边面板中打开{{name}}",
|
||||||
|
openInFocusedPane: "在当前窗格中打开{{name}}",
|
||||||
addProject: "添加 project",
|
addProject: "添加 project",
|
||||||
home: "首页",
|
home: "首页",
|
||||||
groupByProject: "按项目分组",
|
groupByProject: "按项目分组",
|
||||||
@@ -1680,6 +1683,7 @@ export const zhCN: TranslationResources = {
|
|||||||
loading: "正在加载文件...",
|
loading: "正在加载文件...",
|
||||||
noPreview: "没有可用预览",
|
noPreview: "没有可用预览",
|
||||||
binaryPreviewUnavailable: "二进制预览不可用",
|
binaryPreviewUnavailable: "二进制预览不可用",
|
||||||
|
tooLargeToDisplay: "此文件过大,无法显示",
|
||||||
failedToLoad: "加载文件失败",
|
failedToLoad: "加载文件失败",
|
||||||
failedToLoadPreview: "加载文件预览失败",
|
failedToLoadPreview: "加载文件预览失败",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1708,6 +1712,7 @@ export const zhCN: TranslationResources = {
|
|||||||
label: "文件",
|
label: "文件",
|
||||||
subtitle: "工作区文件",
|
subtitle: "工作区文件",
|
||||||
tooltip: "浏览工作区文件",
|
tooltip: "浏览工作区文件",
|
||||||
|
chooseFile: "选择文件",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "拉取请求",
|
label: "拉取请求",
|
||||||
@@ -2316,9 +2321,9 @@ export const zhCN: TranslationResources = {
|
|||||||
featuresLabel: "Features",
|
featuresLabel: "Features",
|
||||||
featureCount: "{{count}} 个 Feature",
|
featureCount: "{{count}} 个 Feature",
|
||||||
featureCountOne: "{{count}} 个 Feature",
|
featureCountOne: "{{count}} 个 Feature",
|
||||||
notesLabel: "给 Agent 的说明",
|
notesLabel: "适用场景",
|
||||||
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
|
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
|
||||||
notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。",
|
notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中...",
|
saving: "保存中...",
|
||||||
remove: "移除",
|
remove: "移除",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
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 =
|
export type KeyboardActionId =
|
||||||
| "agent.interrupt"
|
| "agent.interrupt"
|
||||||
| "message-input.focus"
|
| "message-input.focus"
|
||||||
@@ -19,6 +22,15 @@ export type KeyboardActionId =
|
|||||||
| "workspace.tab.close-current"
|
| "workspace.tab.close-current"
|
||||||
| "workspace.tab.navigate-index"
|
| "workspace.tab.navigate-index"
|
||||||
| "workspace.tab.navigate-relative"
|
| "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.right"
|
||||||
| "workspace.pane.split.down"
|
| "workspace.pane.split.down"
|
||||||
| "workspace.pane.focus.left"
|
| "workspace.pane.focus.left"
|
||||||
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||||
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
| { 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.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
||||||
|
|||||||
@@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
const targetCases = [
|
const targetCases = [
|
||||||
["a", "KeyA", "workspace.tab.target.agent"],
|
["a", "KeyA", "workspace.tab.target.agent"],
|
||||||
["b", "KeyB", "workspace.tab.target.browser"],
|
["b", "KeyB", "workspace.tab.target.browser"],
|
||||||
["c", "KeyC", "workspace.tab.target.changes"],
|
["g", "KeyG", "workspace.tab.target.changes"],
|
||||||
["e", "KeyE", "workspace.tab.target.files"],
|
["e", "KeyE", "workspace.tab.target.files"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
|
|
||||||
it("uses the existing override map for target matching and display", () => {
|
it("uses the existing override map for target matching and display", () => {
|
||||||
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac";
|
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({
|
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" },
|
context: { ...desktopNonMac, focusScope: "other" },
|
||||||
bindings: buildEffectiveBindings(overrides),
|
bindings: buildEffectiveBindings(overrides),
|
||||||
});
|
});
|
||||||
@@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
expect(original.match).toBeNull();
|
expect(original.match).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
|
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",
|
id: "workspace-tab-target-changes-cmd-shift-c-mac",
|
||||||
action: "workspace.tab.target.changes",
|
action: "workspace.tab.target.changes",
|
||||||
combo: "Cmd+Shift+C",
|
combo: "Cmd+Shift+G",
|
||||||
when: { mac: true, commandCenter: false },
|
when: { mac: true, commandCenter: false },
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-target-changes",
|
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",
|
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
|
||||||
action: "workspace.tab.target.changes",
|
action: "workspace.tab.target.changes",
|
||||||
combo: "Ctrl+Shift+C",
|
combo: "Ctrl+Shift+G",
|
||||||
when: { mac: false, commandCenter: false, terminal: false },
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-target-changes",
|
id: "workspace-tab-target-changes",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
|||||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
|
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||||
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
|
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
|
||||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
@@ -748,6 +749,7 @@ function AgentPanelBody({
|
|||||||
return (
|
return (
|
||||||
<ChatAgentContent
|
<ChatAgentContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -760,6 +762,7 @@ function AgentPanelBody({
|
|||||||
|
|
||||||
function ChatAgentContent({
|
function ChatAgentContent({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
client,
|
client,
|
||||||
@@ -768,6 +771,7 @@ function ChatAgentContent({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
client: ReturnType<typeof useHostRuntimeClient>;
|
client: ReturnType<typeof useHostRuntimeClient>;
|
||||||
@@ -1164,6 +1168,7 @@ function ChatAgentContent({
|
|||||||
return (
|
return (
|
||||||
<ChatAgentReadyContent
|
<ChatAgentReadyContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
@@ -1192,6 +1197,7 @@ function ChatAgentContent({
|
|||||||
|
|
||||||
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
isArchivingCurrentAgent,
|
isArchivingCurrentAgent,
|
||||||
@@ -1216,6 +1222,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
@@ -1240,8 +1247,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCompactFormFactor = useIsCompactFormFactor();
|
|
||||||
const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
|
|
||||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||||
@@ -1252,13 +1257,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
rows: subagentRows,
|
rows: subagentRows,
|
||||||
});
|
});
|
||||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||||
const showAgentTracks =
|
const hasVisibleAgentTracks = hasAgentTracks({
|
||||||
hasActiveComposer &&
|
subagentRows,
|
||||||
hasAgentTracks({
|
tasks,
|
||||||
subagentRows,
|
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||||
tasks,
|
});
|
||||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
|
||||||
});
|
|
||||||
const rawAgentInputDraft = useAgentInputDraft({
|
const rawAgentInputDraft = useAgentInputDraft({
|
||||||
draftKey: buildDraftStoreKey({
|
draftKey: buildDraftStoreKey({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
<AgentStreamSection
|
<AgentStreamSection
|
||||||
streamViewRef={streamViewRef}
|
streamViewRef={streamViewRef}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
agent={effectiveAgent}
|
agent={effectiveAgent}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
|
hasActiveComposer={hasActiveComposer}
|
||||||
|
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
||||||
toast={toastApi}
|
toast={toastApi}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
{showAgentTracks ? (
|
{hasActiveComposer ? (
|
||||||
<AgentTracks
|
<AgentTracks
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
subagentRows={subagentRows}
|
subagentRows={subagentRows}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||||
@@ -1409,24 +1415,35 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
const AgentStreamSection = memo(function AgentStreamSection({
|
const AgentStreamSection = memo(function AgentStreamSection({
|
||||||
streamViewRef,
|
streamViewRef,
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
agent,
|
agent,
|
||||||
routeBottomAnchorRequest,
|
routeBottomAnchorRequest,
|
||||||
hasAppliedAuthoritativeHistory,
|
hasAppliedAuthoritativeHistory,
|
||||||
bottomOverlayTailClearance,
|
hasActiveComposer,
|
||||||
|
hasVisibleAgentTracks,
|
||||||
toast,
|
toast,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agent: AgentScreenAgent;
|
agent: AgentScreenAgent;
|
||||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||||
hasAppliedAuthoritativeHistory: boolean;
|
hasAppliedAuthoritativeHistory: boolean;
|
||||||
bottomOverlayTailClearance: number;
|
hasActiveComposer: boolean;
|
||||||
|
hasVisibleAgentTracks: boolean;
|
||||||
toast: ReturnType<typeof useToastHost>["api"];
|
toast: ReturnType<typeof useToastHost>["api"];
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
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) =>
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { memo, useCallback, type ReactElement } from "react";
|
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 { AgentTaskList } from "@/composer/task-list";
|
||||||
import { ComposerTrackBar } from "@/composer/tracks";
|
import { ComposerTrackBar } from "@/composer/tracks";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -15,29 +17,32 @@ import { SubagentsTrack } from "@/subagents/track";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
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
|
* 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
|
* transcript. Each pill owns its action while tab placement stays behind the workspace boundary.
|
||||||
* the agent's stream.
|
|
||||||
*/
|
*/
|
||||||
export const AgentTracks = memo(function AgentTracks({
|
export const AgentTracks = memo(function AgentTracks({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
subagentRows,
|
subagentRows,
|
||||||
tasks,
|
tasks,
|
||||||
archiveFinishedStatus,
|
archiveFinishedStatus,
|
||||||
onArchiveFinished,
|
onArchiveFinished,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
subagentRows: SubagentRow[];
|
subagentRows: SubagentRow[];
|
||||||
tasks: TodoEntry[] | undefined;
|
tasks: TodoEntry[] | undefined;
|
||||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||||
onArchiveFinished: () => void;
|
onArchiveFinished: () => void;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { workspaceId, tabId, openTab } = usePaneContext();
|
const { tabId, openTab } = usePaneContext();
|
||||||
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||||
const openInSidePanelByDefault = useSettings(
|
const openInSidePanelByDefault = useSettings(
|
||||||
@@ -87,13 +92,25 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
},
|
},
|
||||||
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
|
[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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerTrackBar>
|
<ComposerTrackBar>
|
||||||
|
<AgentTaskList tasks={tasks} />
|
||||||
<SubagentsTrack
|
<SubagentsTrack
|
||||||
rows={subagentRows}
|
rows={subagentRows}
|
||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
@@ -103,7 +120,11 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
archiveFinishedStatus={archiveFinishedStatus}
|
archiveFinishedStatus={archiveFinishedStatus}
|
||||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||||
/>
|
/>
|
||||||
<AgentTaskList tasks={tasks} />
|
<WorkspaceDiffStatPill
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
onPress={handleOpenChanges}
|
||||||
|
/>
|
||||||
</ComposerTrackBar>
|
</ComposerTrackBar>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { FolderTree } from "lucide-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 { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
@@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context";
|
|||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import { TreeRail } from "@/components/tree-rail";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
const CENTERED_PADDED_STYLE = {
|
const FILE_TREE_WIDTH = 220;
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 16,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function useFilesPanelDescriptor() {
|
function useFilesPanelDescriptor() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -35,6 +32,7 @@ function FilesPanel() {
|
|||||||
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
||||||
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
invariant(target.kind === "files", "FilesPanel requires files target");
|
invariant(target.kind === "files", "FilesPanel requires files target");
|
||||||
const onOpenFile = useCallback(
|
const onOpenFile = useCallback(
|
||||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||||
@@ -42,19 +40,35 @@ function FilesPanel() {
|
|||||||
);
|
);
|
||||||
if (!workspaceRoot) {
|
if (!workspaceRoot) {
|
||||||
return (
|
return (
|
||||||
<View style={CENTERED_PADDED_STYLE}>
|
<View style={styles.centerState}>
|
||||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<FileExplorerPane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<FileExplorerPane
|
<TreeRail testID="files-tree-rail" width={FILE_TREE_WIDTH}>
|
||||||
serverId={serverId}
|
<View style={styles.centerState}>
|
||||||
workspaceId={workspaceId}
|
<Text style={styles.emptyText}>{t("panels.files.chooseFile")}</Text>
|
||||||
workspaceRoot={workspaceRoot}
|
</View>
|
||||||
onOpenFile={onOpenFile}
|
<FileExplorerPane
|
||||||
onAddToChat={canAddToChat ? addFile : undefined}
|
serverId={serverId}
|
||||||
/>
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
</TreeRail>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = {
|
|||||||
component: FilesPanel,
|
component: FilesPanel,
|
||||||
useDescriptor: useFilesPanelDescriptor,
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
|
|
||||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
import {
|
import {
|
||||||
@@ -441,7 +440,6 @@ export function InlineReviewEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
||||||
const focus = useWorkspaceFocusRestoration();
|
|
||||||
const [body, setBody] = useState(initialBody);
|
const [body, setBody] = useState(initialBody);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const trimmedBody = body.trim();
|
const trimmedBody = body.trim();
|
||||||
@@ -452,13 +450,11 @@ export function InlineReviewEditor({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
focus.unfocus();
|
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
}, [focus]);
|
}, []);
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
focus.restore();
|
}, []);
|
||||||
}, [focus]);
|
|
||||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
|||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { openSupportingTab } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||||
@@ -1169,6 +1171,10 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
}: ResolvedWorkspaceDesktopTabsRowProps) {
|
}: ResolvedWorkspaceDesktopTabsRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const openInSidePanelByDefault = useSettings(
|
||||||
|
(settings) => settings.openSupportingTabsInSidePanel,
|
||||||
|
);
|
||||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||||
@@ -1450,6 +1456,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
[openNewTab, paneId, workspaceKey],
|
[openNewTab, paneId, workspaceKey],
|
||||||
);
|
);
|
||||||
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
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 handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
||||||
const handleOpenPullRequest = useCallback(
|
const handleOpenPullRequest = useCallback(
|
||||||
() => openPanelTarget(PULL_REQUEST_TARGET),
|
() => openPanelTarget(PULL_REQUEST_TARGET),
|
||||||
@@ -1484,7 +1498,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
case "workspace.tab.target.changes":
|
case "workspace.tab.target.changes":
|
||||||
if (!isGit) return true;
|
if (!isGit) return true;
|
||||||
setNewTabMenuOpen(false);
|
setNewTabMenuOpen(false);
|
||||||
handleOpenChanges();
|
handleOpenChangesShortcut();
|
||||||
return true;
|
return true;
|
||||||
case "workspace.tab.target.files":
|
case "workspace.tab.target.files":
|
||||||
setNewTabMenuOpen(false);
|
setNewTabMenuOpen(false);
|
||||||
@@ -1497,7 +1511,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
[
|
[
|
||||||
handleCreateAgentTab,
|
handleCreateAgentTab,
|
||||||
handleCreateBrowser,
|
handleCreateBrowser,
|
||||||
handleOpenChanges,
|
handleOpenChangesShortcut,
|
||||||
handleOpenFiles,
|
handleOpenFiles,
|
||||||
isFocused,
|
isFocused,
|
||||||
isGit,
|
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 { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
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 FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
||||||
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
||||||
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
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. */
|
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
||||||
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
||||||
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
|
|||||||
return keys ? <Shortcut chord={keys} /> : null;
|
return keys ? <Shortcut chord={keys} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
|
const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
|
||||||
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
|
|
||||||
|
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
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
|
dataSet={LAUNCHER_ROW_DATA_SET}
|
||||||
|
tabIndex={-1}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={getLauncherRowStyle}
|
style={getLauncherRowStyle}
|
||||||
>
|
>
|
||||||
@@ -75,6 +90,8 @@ function LauncherRow({
|
|||||||
|
|
||||||
export interface WorkspacePaneEmptyStateProps {
|
export interface WorkspacePaneEmptyStateProps {
|
||||||
paneId: string;
|
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. */
|
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
||||||
showChanges: boolean;
|
showChanges: boolean;
|
||||||
showPullRequest: boolean;
|
showPullRequest: boolean;
|
||||||
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
|
|||||||
|
|
||||||
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
||||||
paneId,
|
paneId,
|
||||||
|
isFocused,
|
||||||
showChanges,
|
showChanges,
|
||||||
showPullRequest,
|
showPullRequest,
|
||||||
onOpenView,
|
onOpenView,
|
||||||
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
onClosePane,
|
onClosePane,
|
||||||
}: WorkspacePaneEmptyStateProps): ReactElement {
|
}: WorkspacePaneEmptyStateProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
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(
|
const openFiles = useCallback(
|
||||||
() => onOpenView({ paneId, target: FILES_TARGET }),
|
() => onOpenView({ paneId, target: FILES_TARGET }),
|
||||||
@@ -119,16 +173,9 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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.rail}>
|
||||||
<View style={styles.stack}>
|
<View style={styles.stack}>
|
||||||
<LauncherRow
|
|
||||||
label={t("workspace.tabs.actions.files")}
|
|
||||||
icon={FILES_ICON}
|
|
||||||
onPress={openFiles}
|
|
||||||
>
|
|
||||||
<LauncherShortcut actionId="workspace-tab-target-files" />
|
|
||||||
</LauncherRow>
|
|
||||||
{showChanges ? (
|
{showChanges ? (
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.changes")}
|
label={t("workspace.tabs.actions.changes")}
|
||||||
@@ -138,6 +185,13 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
<LauncherShortcut actionId="workspace-tab-target-changes" />
|
<LauncherShortcut actionId="workspace-tab-target-changes" />
|
||||||
</LauncherRow>
|
</LauncherRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.files")}
|
||||||
|
icon={FILES_ICON}
|
||||||
|
onPress={openFiles}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-tab-target-files" />
|
||||||
|
</LauncherRow>
|
||||||
{showPullRequest ? (
|
{showPullRequest ? (
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.pullRequest")}
|
label={t("workspace.tabs.actions.pullRequest")}
|
||||||
@@ -146,14 +200,14 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.newAgent")}
|
label={t("workspace.tabs.fallback.agent")}
|
||||||
icon={AGENT_ICON}
|
icon={AGENT_ICON}
|
||||||
onPress={createAgent}
|
onPress={createAgent}
|
||||||
>
|
>
|
||||||
<LauncherShortcut actionId="workspace-tab-target-agent" />
|
<LauncherShortcut actionId="workspace-tab-target-agent" />
|
||||||
</LauncherRow>
|
</LauncherRow>
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.newTerminal")}
|
label={t("workspace.tabs.fallback.terminal")}
|
||||||
icon={TERMINAL_ICON}
|
icon={TERMINAL_ICON}
|
||||||
onPress={createTerminal}
|
onPress={createTerminal}
|
||||||
>
|
>
|
||||||
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
rowHovered: {
|
rowHovered: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
rowFocused: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
|
|||||||
import {
|
import {
|
||||||
isSidePanelOpen,
|
isSidePanelOpen,
|
||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
|
openTabInSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
useIsSidePanelOpen,
|
useIsSidePanelOpen,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
@@ -76,7 +77,10 @@ import {
|
|||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
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 { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import {
|
||||||
buildDeterministicWorkspaceTabId,
|
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({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
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(
|
const handleWorkspaceTabAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const workspaceKeyboardActionsEnabled = Boolean(
|
||||||
|
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-tab-actions",
|
name: "workspace-tab-actions",
|
||||||
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.terminal.new",
|
"workspace.terminal.new",
|
||||||
"workspace.browser.new",
|
"workspace.browser.new",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceTabAction,
|
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({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-pane-actions",
|
name: "workspace-pane-actions",
|
||||||
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.pane.close",
|
"workspace.pane.close",
|
||||||
"workspace.focus.toggle",
|
"workspace.focus.toggle",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspacePaneAction,
|
handle: handleWorkspacePaneAction,
|
||||||
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
}),
|
}),
|
||||||
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceSidebarAction,
|
handle: handleWorkspaceSidebarAction,
|
||||||
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
|
|||||||
function renderSplitPaneEmptyState(input: {
|
function renderSplitPaneEmptyState(input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Same predicate the close action reads, so the button is offered exactly when
|
// Same predicate the close action reads, so the button is offered exactly when
|
||||||
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
|
|||||||
return (
|
return (
|
||||||
<WorkspacePaneEmptyState
|
<WorkspacePaneEmptyState
|
||||||
paneId={input.paneId}
|
paneId={input.paneId}
|
||||||
|
isFocused={input.isFocused}
|
||||||
showChanges={isGitCheckout}
|
showChanges={isGitCheckout}
|
||||||
showPullRequest={hasPullRequest}
|
showPullRequest={hasPullRequest}
|
||||||
onOpenView={handleOpenPaneView}
|
onOpenView={handleOpenPaneView}
|
||||||
|
|||||||
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let tabId = input.createTabId();
|
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;
|
tabId = input.tabId;
|
||||||
} else if (input.target.kind === "draft") {
|
} else if (input.target.kind === "draft") {
|
||||||
tabId = buildDeterministicWorkspaceTabId(input.target);
|
tabId = buildDeterministicWorkspaceTabId(input.target);
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
||||||
store.setTabState(workspaceKey, file, fileState);
|
store.setTabState(workspaceKey, file, fileState);
|
||||||
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
||||||
|
expect(sameKind).toBe(file);
|
||||||
expect(
|
expect(
|
||||||
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
||||||
(tab) => tab.tabId === sameKind,
|
(tab) => tab.tabId === sameKind,
|
||||||
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
|
|||||||
{ kind: "file", path: "/repo/b.ts" },
|
{ kind: "file", path: "/repo/b.ts" },
|
||||||
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
||||||
);
|
);
|
||||||
expect(replacement).not.toBe(file);
|
expect(replacement).toBe(file);
|
||||||
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined();
|
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
|
|
||||||
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
|
|
||||||
|
|
||||||
store.closeTab(workspaceKey, replacement as string);
|
store.closeTab(workspaceKey, replacement as string);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
showSidePanel,
|
showSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
|
toggleSupportingTab,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const WORKSPACE_KEY = "server-1:ws-main";
|
const WORKSPACE_KEY = "server-1:ws-main";
|
||||||
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
|
|||||||
expect(isSidePanelOpen(wide)).toBe(false);
|
expect(isSidePanelOpen(wide)).toBe(false);
|
||||||
expect(tabKinds()).toEqual(["files"]);
|
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", () => {
|
describe("non-compact without splits", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
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,
|
* 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());
|
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) ──────────────────────────────
|
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
|
|||||||
background?: boolean;
|
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
|
* 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
|
* 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
|
* 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
|
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe("Hub commands", () => {
|
|||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
"progress:Logging in to https://hub.paseo.sh",
|
"progress:Logging in to https://hub.paseo.sh",
|
||||||
"authorize:https://hub.paseo.sh",
|
"authorize:https://hub.paseo.sh",
|
||||||
|
"progress:Logged in",
|
||||||
]);
|
]);
|
||||||
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
||||||
});
|
});
|
||||||
@@ -113,11 +114,18 @@ describe("Hub commands", () => {
|
|||||||
events.push("init");
|
events.push("init");
|
||||||
events.push("deploy");
|
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 () => {
|
it("JSON and noninteractive login remain login-only", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, it } from "vitest";
|
import { afterEach, describe, it } from "vitest";
|
||||||
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
||||||
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
||||||
import type { HubHttpClient } from "./hub-client/index.js";
|
import type { HubHttpClient } from "./hub-client/index.js";
|
||||||
@@ -24,11 +25,7 @@ describe("Hub guided setup continuation", () => {
|
|||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
const credentials = new MemoryCredentials();
|
const credentials = new MemoryCredentials();
|
||||||
const daemon = new SetupDaemon();
|
const daemon = new SetupDaemon();
|
||||||
const prompts = new PromptAnswers(
|
const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
|
||||||
[true, true],
|
|
||||||
["codex", "gpt-5", "full-access", "slack"],
|
|
||||||
["U123"],
|
|
||||||
);
|
|
||||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||||
|
|
||||||
@@ -46,24 +43,22 @@ describe("Hub guided setup continuation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(prompts.confirmations, [
|
assert.deepEqual(prompts.confirmations, [
|
||||||
"Connect this daemon to https://hub.test?",
|
"Connect this daemon to this Hub?",
|
||||||
"Initialize and deploy a starter workflow?",
|
"Initialize and deploy a starter workflow?",
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(prompts.selections, [
|
assert.deepEqual(prompts.selections, [
|
||||||
"Starter agent provider",
|
"Starter agent provider",
|
||||||
"Starter agent model",
|
"Starter agent model",
|
||||||
"Starter agent mode",
|
"Starter agent mode",
|
||||||
"Trigger provider",
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(prompts.selectionOptions, [
|
assert.deepEqual(prompts.selectionOptions, [
|
||||||
["Codex (suggested)"],
|
["Codex"],
|
||||||
["GPT-5 (suggested)", "GPT-5 mini"],
|
["GPT-5 (suggested)", "GPT-5 mini"],
|
||||||
["Read only", "Full access (suggested)"],
|
["Read only", "Full access (suggested)"],
|
||||||
[
|
]);
|
||||||
"GitHub (issue or pull request comment)",
|
assert.deepEqual(prompts.messages, [
|
||||||
"Slack (channel mention)",
|
"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.",
|
||||||
"Discord (channel mention)",
|
"Using Slack — Paseo",
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ operation: "token", origin: "https://hub.test" },
|
{ 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"]);
|
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 () => {
|
it("keeps hub init's existing replacement confirmation", async () => {
|
||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
await mkdir(path.join(cwd, ".paseo"));
|
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[] }> = [];
|
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||||
|
|
||||||
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
||||||
@@ -162,6 +187,52 @@ describe("Hub guided setup continuation", () => {
|
|||||||
["validate", "install"],
|
["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(
|
function setupEnvironment(
|
||||||
@@ -170,6 +241,9 @@ function setupEnvironment(
|
|||||||
daemon: SetupDaemon,
|
daemon: SetupDaemon,
|
||||||
prompts: PromptAnswers,
|
prompts: PromptAnswers,
|
||||||
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
||||||
|
options: {
|
||||||
|
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
|
||||||
|
} = {},
|
||||||
): HubGuidedSetupEnvironment {
|
): HubGuidedSetupEnvironment {
|
||||||
const hub = {
|
const hub = {
|
||||||
async issueEnrollmentToken(origin: string) {
|
async issueEnrollmentToken(origin: string) {
|
||||||
@@ -182,11 +256,13 @@ function setupEnvironment(
|
|||||||
},
|
},
|
||||||
async listSetupResources(origin: string) {
|
async listSetupResources(origin: string) {
|
||||||
calls.push({ operation: "setup", origin });
|
calls.push({ operation: "setup", origin });
|
||||||
return {
|
return (
|
||||||
github: [],
|
options.setupResources ?? {
|
||||||
discord: [],
|
github: [],
|
||||||
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
discord: [],
|
||||||
};
|
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async listConfigurationResources(origin: string) {
|
async listConfigurationResources(origin: string) {
|
||||||
calls.push({ operation: "resources", origin });
|
calls.push({ operation: "resources", origin });
|
||||||
@@ -294,7 +370,7 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
private origin: string | null = null;
|
private origin: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly providerEntries = [
|
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
status: "ready" as const,
|
status: "ready" as const,
|
||||||
@@ -311,6 +387,7 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
defaultModeId: "full-access",
|
defaultModeId: "full-access",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async connectHub(origin: string) {
|
async connectHub(origin: string) {
|
||||||
@@ -325,7 +402,11 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
|
|
||||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||||
this.snapshotCwds.push(options?.cwd);
|
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() {
|
async disconnectHub() {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
planHubInitOpening,
|
planHubInitOpening,
|
||||||
resolveHubInitConnection,
|
resolveHubInitConnection,
|
||||||
resolveHubInitProjects,
|
resolveHubInitProjects,
|
||||||
type HubInitProvider,
|
|
||||||
} from "./init-plan.js";
|
} from "./init-plan.js";
|
||||||
import type { CliLoginFlow } from "./login-flow.js";
|
import type { CliLoginFlow } from "./login-flow.js";
|
||||||
import { runHubConnect } from "./connect.js";
|
import { runHubConnect } from "./connect.js";
|
||||||
@@ -43,16 +42,21 @@ import { runHubProjects } from "./projects.js";
|
|||||||
import type { HubReporter } from "./reporter.js";
|
import type { HubReporter } from "./reporter.js";
|
||||||
import { normalizeHubOrigin } from "./origin.js";
|
import { normalizeHubOrigin } from "./origin.js";
|
||||||
import {
|
import {
|
||||||
availableStarterAgentProviders,
|
|
||||||
selectedStarterAgentRuntime,
|
selectedStarterAgentRuntime,
|
||||||
|
starterAgentProviderSnapshotState,
|
||||||
suggestedStarterAgentChoice,
|
suggestedStarterAgentChoice,
|
||||||
type HubStarterAgentProvider,
|
type HubStarterAgentProvider,
|
||||||
type HubStarterAgentRuntime,
|
type HubStarterAgentRuntime,
|
||||||
} from "./starter-agent-runtime.js";
|
} from "./starter-agent-runtime.js";
|
||||||
|
import {
|
||||||
|
availableStarterTriggerConnections,
|
||||||
|
type HubStarterTriggerConnection,
|
||||||
|
} from "./starter-trigger.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
||||||
const DAEMON_READY_POLL_MS = 250;
|
const DAEMON_READY_POLL_MS = 250;
|
||||||
|
const PROVIDER_READY_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export interface HubGuidedSetupEnvironment {
|
export interface HubGuidedSetupEnvironment {
|
||||||
env: Readonly<Record<string, string | undefined>>;
|
env: Readonly<Record<string, string | undefined>>;
|
||||||
@@ -132,16 +136,18 @@ export async function runHubGuidedSetup(
|
|||||||
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
|
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
|
||||||
const project = await chooseProject(origin, environment);
|
const project = await chooseProject(origin, environment);
|
||||||
const resources = await loadSetupResources(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);
|
const daemon = await loadDaemonResource(origin, daemonId, environment);
|
||||||
log.success(`Connected as ${daemon.slug}`);
|
log.success(`Connected as ${daemon.slug}`);
|
||||||
const agent = await chooseStarterAgentRuntime(environment, cwd);
|
const agent = await chooseStarterAgentRuntime(environment, cwd);
|
||||||
const provider = await chooseProvider(environment);
|
const providerFilters = await collectProviderIdentity(trigger, environment);
|
||||||
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
|
|
||||||
const scaffold = createHubInitScaffold({
|
const scaffold = createHubInitScaffold({
|
||||||
cwd,
|
cwd,
|
||||||
daemonSlug: daemon.slug,
|
daemonSlug: daemon.slug,
|
||||||
agent,
|
agent,
|
||||||
provider,
|
provider: trigger.provider,
|
||||||
providerFilters,
|
providerFilters,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +191,7 @@ export async function continueHubGuidedSetup(
|
|||||||
origin: string,
|
origin: string,
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
|
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
|
||||||
reportMessage(
|
reportMessage(
|
||||||
environment,
|
environment,
|
||||||
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
||||||
@@ -200,7 +206,20 @@ export async function continueHubGuidedSetup(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
|
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(
|
async function ensureLogin(
|
||||||
@@ -370,31 +389,61 @@ async function chooseProject(
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
|
async function resolveStarterTriggerConnections(
|
||||||
return requiredSelect(environment, {
|
resources: HubSetupResources,
|
||||||
message: "Trigger provider",
|
cwd: string,
|
||||||
options: [
|
): Promise<HubStarterTriggerConnection[]> {
|
||||||
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
|
const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
|
||||||
{ value: "slack", label: "Slack", hint: "channel mention" },
|
const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
|
||||||
{ value: "discord", label: "Discord", hint: "channel mention" },
|
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(
|
async function chooseStarterAgentRuntime(
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<HubStarterAgentRuntime> {
|
): Promise<HubStarterAgentRuntime> {
|
||||||
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) =>
|
const providers = await waitForStarterAgentProviders(environment, cwd);
|
||||||
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 provider = await chooseStarterAgentProvider(environment, providers);
|
const provider = await chooseStarterAgentProvider(environment, providers);
|
||||||
const model = await chooseStarterAgentModel(environment, provider);
|
const model = await chooseStarterAgentModel(environment, provider);
|
||||||
const mode = await chooseStarterAgentMode(environment, provider);
|
const mode = await chooseStarterAgentMode(environment, provider);
|
||||||
@@ -408,18 +457,45 @@ async function chooseStarterAgentRuntime(
|
|||||||
return runtime;
|
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(
|
async function chooseStarterAgentProvider(
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
providers: readonly HubStarterAgentProvider[],
|
providers: readonly HubStarterAgentProvider[],
|
||||||
): Promise<HubStarterAgentProvider> {
|
): Promise<HubStarterAgentProvider> {
|
||||||
const suggested = suggestedStarterAgentChoice(providers);
|
|
||||||
const selected = await requiredSelect(environment, {
|
const selected = await requiredSelect(environment, {
|
||||||
message: "Starter agent provider",
|
message: "Starter agent provider",
|
||||||
...(suggested === undefined ? {} : { initialValue: suggested.id }),
|
|
||||||
options: providers.map((provider) => ({
|
options: providers.map((provider) => ({
|
||||||
value: provider.id,
|
value: provider.id,
|
||||||
label: provider.label,
|
label: provider.label,
|
||||||
...(provider.suggested ? { hint: "suggested" } : {}),
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const provider = providers.find((candidate) => candidate.id === selected);
|
const provider = providers.find((candidate) => candidate.id === selected);
|
||||||
@@ -471,57 +547,30 @@ function invalidStarterAgentSelection(): HubCommandError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectProviderFilters(
|
async function collectProviderIdentity(
|
||||||
provider: HubInitProvider,
|
trigger: HubStarterTriggerConnection,
|
||||||
resources: HubSetupResources,
|
|
||||||
cwd: string,
|
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
): Promise<Readonly<Record<string, string>>> {
|
): Promise<Readonly<Record<string, string>>> {
|
||||||
if (provider === "github") {
|
if (trigger.provider === "github") {
|
||||||
const [login, originRemote] = await Promise.all([
|
const login = await readGhValue(["api", "user", "--jq", ".login"]);
|
||||||
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"}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
|
...trigger.filters,
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your GitHub username (only this user can trigger the bot)",
|
message: "Your GitHub username (only this user can trigger the bot)",
|
||||||
initialValue: login,
|
initialValue: login,
|
||||||
}),
|
}),
|
||||||
repo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (provider === "slack") {
|
if (trigger.provider === "slack") {
|
||||||
return {
|
return {
|
||||||
workspace: await chooseConnection(
|
...trigger.filters,
|
||||||
environment,
|
|
||||||
"Slack workspace",
|
|
||||||
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
|
|
||||||
),
|
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your Slack member ID (only this user can trigger the bot)",
|
message: "Your Slack member ID (only this user can trigger the bot)",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
guild: await chooseConnection(
|
...trigger.filters,
|
||||||
environment,
|
|
||||||
"Discord server",
|
|
||||||
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
|
|
||||||
),
|
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your Discord user ID (only this user can trigger the bot)",
|
message: "Your Discord user ID (only this user can trigger the bot)",
|
||||||
}),
|
}),
|
||||||
@@ -579,24 +628,6 @@ async function loadDaemonResource(
|
|||||||
return daemon;
|
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(
|
async function writeScaffold(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
scaffold: ReturnType<typeof createHubInitScaffold>,
|
scaffold: ReturnType<typeof createHubInitScaffold>,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function runHubLogin(
|
|||||||
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
||||||
const credential = await dependencies.flow.authorize(origin);
|
const credential = await dependencies.flow.authorize(origin);
|
||||||
dependencies.credentials.save({ origin, credential });
|
dependencies.credentials.save({ origin, credential });
|
||||||
|
reportHubProgress(dependencies.reporter, options, "Logged in");
|
||||||
if (
|
if (
|
||||||
!options.json &&
|
!options.json &&
|
||||||
dependencies.isInteractive?.() &&
|
dependencies.isInteractive?.() &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
availableStarterAgentProviders,
|
availableStarterAgentProviders,
|
||||||
selectedStarterAgentRuntime,
|
selectedStarterAgentRuntime,
|
||||||
|
starterAgentProviderSnapshotState,
|
||||||
suggestedStarterAgentChoice,
|
suggestedStarterAgentChoice,
|
||||||
} from "./starter-agent-runtime.js";
|
} from "./starter-agent-runtime.js";
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
suggested: true,
|
|
||||||
models: [
|
models: [
|
||||||
{ id: "gpt-5", label: "GPT-5", suggested: true },
|
{ id: "gpt-5", label: "GPT-5", suggested: true },
|
||||||
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
|
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
|
||||||
@@ -59,7 +59,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
label: "Claude",
|
label: "Claude",
|
||||||
suggested: false,
|
|
||||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||||
},
|
},
|
||||||
@@ -81,7 +80,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
label: "claude",
|
label: "claude",
|
||||||
suggested: false,
|
|
||||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||||
},
|
},
|
||||||
@@ -118,4 +116,16 @@ describe("starter agent runtime choices", () => {
|
|||||||
});
|
});
|
||||||
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
|
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;
|
label: string;
|
||||||
models: readonly HubStarterAgentModel[];
|
models: readonly HubStarterAgentModel[];
|
||||||
modes: readonly HubStarterAgentMode[];
|
modes: readonly HubStarterAgentMode[];
|
||||||
suggested: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HubStarterAgentModel {
|
export interface HubStarterAgentModel {
|
||||||
@@ -26,6 +25,11 @@ export interface HubStarterAgentMode {
|
|||||||
suggested: boolean;
|
suggested: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HubStarterAgentProviderSnapshotState =
|
||||||
|
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "unavailable" };
|
||||||
|
|
||||||
export function availableStarterAgentProviders(
|
export function availableStarterAgentProviders(
|
||||||
entries: readonly ProviderSnapshotEntry[],
|
entries: readonly ProviderSnapshotEntry[],
|
||||||
): HubStarterAgentProvider[] {
|
): HubStarterAgentProvider[] {
|
||||||
@@ -47,14 +51,23 @@ export function availableStarterAgentProviders(
|
|||||||
label: entry.label ?? entry.provider,
|
label: entry.label ?? entry.provider,
|
||||||
models,
|
models,
|
||||||
modes,
|
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 }>(
|
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
|
||||||
choices: readonly T[],
|
choices: readonly T[],
|
||||||
): T | undefined {
|
): 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 },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
|||||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
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 () => {
|
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
|
|||||||
interface PendingBinaryFileRead {
|
interface PendingBinaryFileRead {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
maxBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
||||||
@@ -4308,6 +4309,7 @@ export class DaemonClient {
|
|||||||
mode: "list" | "file",
|
mode: "list" | "file",
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
acceptBinary = false,
|
acceptBinary = false,
|
||||||
|
maxBytes?: number,
|
||||||
): Promise<FileExplorerPayload> {
|
): Promise<FileExplorerPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
@@ -4317,6 +4319,7 @@ export class DaemonClient {
|
|||||||
path,
|
path,
|
||||||
mode,
|
mode,
|
||||||
...(acceptBinary ? { acceptBinary: true } : {}),
|
...(acceptBinary ? { acceptBinary: true } : {}),
|
||||||
|
...(maxBytes ? { maxBytes } : {}),
|
||||||
},
|
},
|
||||||
responseType: "file_explorer_response",
|
responseType: "file_explorer_response",
|
||||||
});
|
});
|
||||||
@@ -4337,11 +4340,23 @@ export class DaemonClient {
|
|||||||
return payload.directory;
|
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);
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||||
try {
|
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) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -5761,10 +5776,33 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
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);
|
transfer.chunks.push(frame.payload);
|
||||||
return;
|
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);
|
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
this.completedBinaryFileReads.set(frame.requestId, {
|
this.completedBinaryFileReads.set(frame.requestId, {
|
||||||
|
|||||||
@@ -2555,6 +2555,7 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
mode: z.enum(["list", "file"]),
|
mode: z.enum(["list", "file"]),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
acceptBinary: z.boolean().optional(),
|
acceptBinary: z.boolean().optional(),
|
||||||
|
maxBytes: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FileVersionSchema = z.discriminatedUnion("status", [
|
export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string {
|
|||||||
return new TextDecoder().decode(decrypt(sharedKey, ciphertext));
|
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("crypto", () => {
|
||||||
describe("generateKeyPair", () => {
|
describe("generateKeyPair", () => {
|
||||||
it("generates a valid keypair", () => {
|
it("generates a valid keypair", () => {
|
||||||
@@ -36,6 +40,22 @@ describe("crypto", () => {
|
|||||||
const reExported = exportPublicKey(imported);
|
const reExported = exportPublicKey(imported);
|
||||||
expect(reExported).toBe(exported);
|
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", () => {
|
describe("deriveSharedKey", () => {
|
||||||
@@ -63,6 +83,34 @@ describe("crypto", () => {
|
|||||||
|
|
||||||
expect(decrypted).toBe(testMessage);
|
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", () => {
|
describe("encrypt / decrypt", () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface KeyPair {
|
|||||||
export type SharedKey = Uint8Array; // 32 bytes (box.before)
|
export type SharedKey = Uint8Array; // 32 bytes (box.before)
|
||||||
|
|
||||||
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
||||||
|
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);
|
||||||
|
|
||||||
let prngReady = false;
|
let prngReady = false;
|
||||||
|
|
||||||
@@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array {
|
|||||||
return toByteArray(base64);
|
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 {
|
function toUint8(data: string | ArrayBuffer): Uint8Array {
|
||||||
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
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 {
|
export function importPublicKey(base64: string): Uint8Array {
|
||||||
const bytes = decodeBase64(base64);
|
const bytes = decodePublicKeyBase64(base64);
|
||||||
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
||||||
throw new Error(`Invalid public key length (expected ${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) {
|
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
|
||||||
throw new Error(`Invalid peer public key length (expected ${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);
|
return nacl.box.before(peerPublicKey, ourSecretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@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",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.5.0-beta.4",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.5.0-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { expect, it, test, vi } from "vitest";
|
import { expect, it, test, vi } from "vitest";
|
||||||
import pino, { type Logger } from "pino";
|
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 { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentManager } from "./agent-manager.js";
|
import { AgentManager } from "./agent-manager.js";
|
||||||
@@ -8,8 +12,15 @@ import {
|
|||||||
formatSystemNotificationPrompt,
|
formatSystemNotificationPrompt,
|
||||||
isSystemInjectedEnvelope,
|
isSystemInjectedEnvelope,
|
||||||
setupFinishNotification,
|
setupFinishNotification,
|
||||||
|
waitForAgentRunStartWithTimeout,
|
||||||
} from "./agent-prompt.js";
|
} from "./agent-prompt.js";
|
||||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||||
|
import type {
|
||||||
|
AgentClient,
|
||||||
|
AgentRunResult,
|
||||||
|
AgentSession,
|
||||||
|
AgentStreamEvent,
|
||||||
|
} from "./agent-sdk-types.js";
|
||||||
|
|
||||||
interface CapturedLogger {
|
interface CapturedLogger {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -520,3 +531,234 @@ it("does not notify archived callers", async () => {
|
|||||||
expect(streamAgentSpy).not.toHaveBeenCalled();
|
expect(streamAgentSpy).not.toHaveBeenCalled();
|
||||||
expect(replaceAgentRunSpy).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;
|
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(
|
export async function waitForAgentRunStartWithTimeout(
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
|
||||||
const startAbort = new AbortController();
|
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 {
|
try {
|
||||||
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
||||||
|
|||||||
@@ -343,6 +343,81 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60_000);
|
}, 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 () => {
|
test("archives and unarchives the durable native session through client hooks", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const runtime = new TestOpenCodeHarness();
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
|||||||
@@ -1396,10 +1396,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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(
|
const response = await withTimeout(
|
||||||
client.session.create({ directory: openCodeConfig.cwd }),
|
client.session.create({ directory: openCodeConfig.cwd }),
|
||||||
10_000,
|
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
|
||||||
"OpenCode session.create timed out after 10s",
|
`OpenCode session.create timed out after ${Math.round(
|
||||||
|
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
|
||||||
|
)}s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
|||||||
+4
@@ -145,6 +145,7 @@ export class TestOpenCodeClient {
|
|||||||
sessionCommandEvents: unknown[] = [idleEvent()];
|
sessionCommandEvents: unknown[] = [idleEvent()];
|
||||||
sessionCommandResponse: OpenCodeResponse = {};
|
sessionCommandResponse: OpenCodeResponse = {};
|
||||||
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
||||||
|
sessionCreateImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
|
||||||
sessionDeleteResponse: OpenCodeResponse = {};
|
sessionDeleteResponse: OpenCodeResponse = {};
|
||||||
sessionChildrenResponses: OpenCodeResponse[] = [];
|
sessionChildrenResponses: OpenCodeResponse[] = [];
|
||||||
sessionChildrenImplementation:
|
sessionChildrenImplementation:
|
||||||
@@ -294,6 +295,9 @@ export class TestOpenCodeClient {
|
|||||||
},
|
},
|
||||||
create: async (parameters: unknown) => {
|
create: async (parameters: unknown) => {
|
||||||
this.calls.sessionCreate.push(parameters);
|
this.calls.sessionCreate.push(parameters);
|
||||||
|
if (this.sessionCreateImplementation) {
|
||||||
|
return await this.sessionCreateImplementation(parameters);
|
||||||
|
}
|
||||||
return this.sessionCreateResponse;
|
return this.sessionCreateResponse;
|
||||||
},
|
},
|
||||||
delete: async (parameters: unknown) => {
|
delete: async (parameters: unknown) => {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer {
|
|||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
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 {
|
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
|
||||||
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
|
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
|
||||||
return JSON.parse(plaintext);
|
return JSON.parse(plaintext);
|
||||||
@@ -161,35 +166,48 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
|
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", () => {
|
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
|
||||||
let relayPort: number;
|
let relayPort: number;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
let relayStdoutLines: string[] = [];
|
let relayStdoutLines: string[] = [];
|
||||||
|
|
||||||
const startRelay = async () => {
|
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
|
||||||
relayStdoutLines = [];
|
relayStdoutLines = [];
|
||||||
relayPort = await getAvailablePort();
|
relayPort = await getAvailablePort();
|
||||||
const relayDir = path.resolve(process.cwd(), "../relay");
|
const relayDir = path.resolve(process.cwd(), "../relay");
|
||||||
relayProcess = spawn(
|
const relayArgs = [
|
||||||
"npx",
|
"wrangler",
|
||||||
[
|
"dev",
|
||||||
"wrangler",
|
"--local",
|
||||||
"dev",
|
"--ip",
|
||||||
"--local",
|
"127.0.0.1",
|
||||||
"--ip",
|
"--port",
|
||||||
"127.0.0.1",
|
String(relayPort),
|
||||||
"--port",
|
"--live-reload=false",
|
||||||
String(relayPort),
|
"--show-interactive-dev-session=false",
|
||||||
"--live-reload=false",
|
];
|
||||||
"--show-interactive-dev-session=false",
|
if (options.useLocalRelay) {
|
||||||
],
|
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
|
||||||
{
|
}
|
||||||
cwd: relayDir,
|
relayProcess = spawn("npx", relayArgs, {
|
||||||
env: { ...process.env },
|
cwd: relayDir,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
env: { ...process.env },
|
||||||
detached: false,
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
detached: false,
|
||||||
);
|
});
|
||||||
|
|
||||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data
|
const lines = data
|
||||||
@@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
}
|
}
|
||||||
}, 90000);
|
}, 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 () => {
|
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
|
||||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
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 () => {
|
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
|
||||||
const cwd = makeDir("workspace-files-large-binary-");
|
const cwd = makeDir("workspace-files-large-binary-");
|
||||||
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
|
|||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
} else {
|
} 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()) {
|
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
||||||
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
||||||
await this.host.emitBinary(
|
await this.host.emitBinary(
|
||||||
|
|||||||
+8
-1
@@ -263,6 +263,7 @@ Use `PASEO_HOME` to run multiple isolated daemon instances.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo hub login [url] # Approve and store organization-scoped CLI access
|
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 connect [url] # Enroll this daemon using CLI access
|
||||||
paseo hub projects # List projects in the authenticated organization
|
paseo hub projects # List projects in the authenticated organization
|
||||||
paseo hub status # Show the current Hub relationship
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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`.
|
6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`.
|
||||||
|
|||||||
@@ -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.
|
[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
|
## Sources
|
||||||
|
|
||||||
A configuration comes from one source:
|
A configuration comes from one source:
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ Log in from the machine first:
|
|||||||
paseo hub login https://hub.example.com
|
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
|
```sh
|
||||||
paseo hub connect
|
paseo hub connect
|
||||||
|
|||||||
@@ -28,18 +28,16 @@ Your daemons keep running agents where they always did. Hub decides when to ask
|
|||||||
|
|
||||||
## What lives in your repository
|
## 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
|
```text
|
||||||
.paseo/
|
.paseo/
|
||||||
├── hub.yml
|
├── hub.yml
|
||||||
└── workflows/
|
└── workflows/
|
||||||
├── slack-help.yml
|
└── slack-help.yml
|
||||||
└── partials/
|
|
||||||
└── answer.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Reading order
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ category: Hub
|
|||||||
|
|
||||||
# Hub quickstart
|
# 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.
|
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
|
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
|
## 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:
|
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
|
/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
|
```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;
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
```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.
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ steps:
|
|||||||
- { type: discord.reply, max: 1, required: true }
|
- { 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.
|
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).
|
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.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ steps:
|
|||||||
- { type: slack.reply, max: 1, required: true }
|
- { 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.
|
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).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user