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

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

* feat(app): expand workspace panel actions

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

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

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

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

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

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

Related to #3571.

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

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

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

* fix(server): describe agent run start timeouts

---------

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