Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e7127aed | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 |
+5
-1
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
|
|||||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||||
to it.
|
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
||||||
|
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
||||||
|
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
||||||
|
hides the panel without closing the tab. A different visible Side panel tab switches to
|
||||||
|
Changes instead.
|
||||||
|
|
||||||
## The name
|
## The name
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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
@@ -1 +1 @@
|
|||||||
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
|
sha256-+fewwjlLlJN9VXiBOGvALNQgNxnoVXYQzBDra3ylJhA=
|
||||||
|
|||||||
Generated
+2
-2
@@ -38881,7 +38881,7 @@
|
|||||||
"version": "0.5.0-beta.4",
|
"version": "0.5.0-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.5.0-beta.4",
|
||||||
@@ -38890,7 +38890,7 @@
|
|||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.5.0-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
|
|||||||
@@ -996,6 +996,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
|
|||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||||
|
const focusedBackground = await changesTab.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
await startReviewOnFirstChangedLine(page);
|
||||||
|
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||||
|
await expect
|
||||||
|
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe(focusedBackground);
|
||||||
|
});
|
||||||
|
|
||||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1095,7 +1112,7 @@ test("canvas diff copies a dragged character selection without opening a review"
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
|
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
@@ -1105,7 +1122,7 @@ test("the first click after a canvas selection only dismisses the selection", as
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
|
|
||||||
await clickFirstChangedLine(page);
|
await clickFirstChangedLine(page);
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||||
@@ -1537,7 +1554,8 @@ async function startReviewOnFirstChangedLine(
|
|||||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||||
const lineHeight = Math.round(fontSize * 1.5);
|
const lineHeight = Math.round(fontSize * 1.5);
|
||||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||||
|
await page.getByRole("button", { name: "Add review comment" }).click();
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||||
|
|
||||||
|
function mainPane(page: Page) {
|
||||||
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidePanel(page: Page) {
|
||||||
|
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||||
|
|
||||||
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||||
|
|
||||||
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(sidePanel(page)).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
function visibleMainPane(page: Page) {
|
||||||
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedChangedAgent(repoPrefix: string) {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix,
|
||||||
|
title: "Composer diff stat",
|
||||||
|
repo: { withRemote: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.cwd, "README.md"),
|
||||||
|
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||||
|
);
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const workspaces = await workspace.client.fetchWorkspaces();
|
||||||
|
return (
|
||||||
|
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toEqual({ additions: 2, deletions: 0 });
|
||||||
|
return workspace;
|
||||||
|
} catch (error) {
|
||||||
|
await workspace.cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||||
|
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||||
|
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(pill).toContainText("+2");
|
||||||
|
await expect(pill).toContainText("-0");
|
||||||
|
await pill.click();
|
||||||
|
|
||||||
|
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||||
|
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||||
|
await openFilesPanel(page);
|
||||||
|
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||||
|
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("clicking again restores Changes", async () => {
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
||||||
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("composer-diff-stat-pill").click();
|
||||||
|
|
||||||
|
const mainPane = visibleMainPane(page);
|
||||||
|
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function visibleEmptyPane(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-pane-empty-state").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||||
|
await expect(launcher.getByRole("button", { name })).toBeFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+E`);
|
||||||
|
const launcher = page
|
||||||
|
.getByTestId("workspace-side-panel")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByTestId("workspace-pane-empty-state");
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
return launcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Empty pane keyboard launcher", () => {
|
||||||
|
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
const launcher = await openSidePanelWithKeyboard(page);
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Files");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("workspace-side-panel")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByTestId("workspace-tab-files"),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new split pane focuses its launcher and wraps arrow navigation", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
|
||||||
|
const launcher = visibleEmptyPane(page);
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp");
|
||||||
|
await expectLauncherSelection(launcher, "Terminal");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-lockfile-",
|
||||||
|
title: "Large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||||
|
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "package-lock.json");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText('"package-0"');
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-plain-",
|
||||||
|
title: "Plain large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "plain.txt"),
|
||||||
|
"plain source\n".repeat(1_050_000),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "plain.txt");
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText("plain source");
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-unsupported-",
|
||||||
|
title: "Unsupported large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "too-large.txt"),
|
||||||
|
Buffer.alloc(51 * 1024 * 1024),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "too-large.txt");
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||||
|
"This file is too large to display",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
|
|||||||
finalText: string,
|
finalText: string,
|
||||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||||
) {
|
) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
|
||||||
|
).__fileTransitionOriginalChip = originalChip;
|
||||||
|
});
|
||||||
await page.evaluate((tabTestId) => {
|
await page.evaluate((tabTestId) => {
|
||||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
|
|||||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||||
|
|
||||||
|
const chipIdentity = await page.evaluate((tabTestId) => {
|
||||||
|
const windowWithOriginal = window as typeof window & {
|
||||||
|
__fileTransitionOriginalChip?: Element | null;
|
||||||
|
};
|
||||||
|
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
|
||||||
|
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||||
|
return {
|
||||||
|
originalStillConnected: originalChip?.isConnected ?? false,
|
||||||
|
sameNode: originalChip === currentChip,
|
||||||
|
};
|
||||||
|
}, `workspace-tab-file_${targetPath}`);
|
||||||
|
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
|
||||||
|
|
||||||
const heldTimeline = await page.evaluate(
|
const heldTimeline = await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
|
|||||||
|
|
||||||
let workspace: SeededWorkspace;
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
|
||||||
|
return locator.evaluate((element) => {
|
||||||
|
let current: Element | null = element;
|
||||||
|
while (current) {
|
||||||
|
const color = getComputedStyle(current).backgroundColor;
|
||||||
|
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return "transparent";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
workspace = await seedWorkspace({
|
workspace = await seedWorkspace({
|
||||||
repoPrefix: "file-tree-rail-toggle-",
|
repoPrefix: "file-tree-rail-toggle-",
|
||||||
@@ -21,6 +33,21 @@ test.afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("File panel tree rail", () => {
|
test.describe("File panel tree rail", () => {
|
||||||
|
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
|
||||||
|
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||||
|
const content = rail.getByTestId("files-tree-rail-content");
|
||||||
|
const tree = rail.getByTestId("files-tree-rail-tree");
|
||||||
|
await expect(content).toHaveText("Choose a file");
|
||||||
|
await expect(tree).toContainText("docs");
|
||||||
|
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
|
||||||
|
await effectiveBackground(content),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await openFileExplorer(page);
|
await openFileExplorer(page);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ test.describe("Tab creation", () => {
|
|||||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ function emptyState(scope: Locator): Locator {
|
|||||||
return scope.getByTestId("workspace-pane-empty-state");
|
return scope.getByTestId("workspace-pane-empty-state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
|
||||||
|
return launcher
|
||||||
|
.getByRole("button")
|
||||||
|
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
|
||||||
|
}
|
||||||
|
|
||||||
function sidePanelToggle(page: Page): Locator {
|
function sidePanelToggle(page: Page): Locator {
|
||||||
return page.getByTestId("workspace-explorer-toggle").first();
|
return page.getByTestId("workspace-explorer-toggle").first();
|
||||||
}
|
}
|
||||||
@@ -61,7 +67,15 @@ test.describe("Side panel", () => {
|
|||||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||||
await sidePanelToggle(page).click();
|
await sidePanelToggle(page).click();
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
const launcher = emptyState(sidePanel(page));
|
||||||
|
await expect(launcher).toBeVisible();
|
||||||
|
expect(await launcherButtonLabels(launcher)).toEqual([
|
||||||
|
"Changes",
|
||||||
|
"Files",
|
||||||
|
"Agent",
|
||||||
|
"Terminal",
|
||||||
|
null,
|
||||||
|
]);
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
|||||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||||
}
|
}
|
||||||
if (draft.notes !== undefined) {
|
if (draft.notes !== undefined) {
|
||||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
|
|||||||
const modal = editModal(page);
|
const modal = editModal(page);
|
||||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||||
|
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
|
||||||
|
await expect(notesHint).toHaveText(
|
||||||
|
"Helps agents choose this profile when starting another agent.",
|
||||||
|
);
|
||||||
|
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
|
||||||
|
"1",
|
||||||
|
);
|
||||||
for (const [field, value] of Object.entries(expected)) {
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
if (field === "notes") {
|
if (field === "notes") {
|
||||||
await expect(
|
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
value,
|
||||||
).toHaveValue(value);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
|||||||
export interface MockAgentOptions {
|
export interface MockAgentOptions {
|
||||||
repoPrefix: string;
|
repoPrefix: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||||
port?: number;
|
port?: number;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
|||||||
export async function seedMockAgentWorkspace(
|
export async function seedMockAgentWorkspace(
|
||||||
options: MockAgentOptions,
|
options: MockAgentOptions,
|
||||||
): Promise<MockAgentWorkspace> {
|
): Promise<MockAgentWorkspace> {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: options.repoPrefix,
|
||||||
|
repo: options.repo,
|
||||||
|
port: options.port,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const agent = await workspace.client.createAgent({
|
const agent = await workspace.client.createAgent({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
|||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube,
|
TestTube,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "./profile-appearance";
|
} from "./profile-appearance";
|
||||||
|
|
||||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
const ThemedDefaultIcon = withUnistyles(Star);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||||
|
|||||||
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
|
|||||||
newBrowser: "New browser",
|
newBrowser: "New browser",
|
||||||
splitRight: "Split pane right",
|
splitRight: "Split pane right",
|
||||||
splitDown: "Split pane down",
|
splitDown: "Split pane down",
|
||||||
|
changes: "Changes",
|
||||||
|
files: "Files",
|
||||||
|
pullRequest: "Pull request",
|
||||||
|
openPanel: (name, placement) => `Open ${name} ${placement}`,
|
||||||
|
previousTab: "Previous tab",
|
||||||
|
nextTab: "Next tab",
|
||||||
|
closeCurrentTab: "Close current tab",
|
||||||
|
renameTab: "Rename",
|
||||||
|
reloadAgent: "Reload agent",
|
||||||
|
copyResumeCommand: "Copy resume command",
|
||||||
|
copyAgentId: "Copy agent id",
|
||||||
|
copyTerminalId: "Copy terminal id",
|
||||||
|
copyFilePath: "Copy file path",
|
||||||
|
closeTabsLeft: "Close tabs left",
|
||||||
|
closeTabsRight: "Close tabs right",
|
||||||
|
closeOtherTabs: "Close other tabs",
|
||||||
|
focusPaneLeft: "Focus pane left",
|
||||||
|
focusPaneRight: "Focus pane right",
|
||||||
|
focusPaneUp: "Focus pane up",
|
||||||
|
focusPaneDown: "Focus pane down",
|
||||||
|
moveTabLeft: "Move tab left",
|
||||||
|
moveTabRight: "Move tab right",
|
||||||
|
moveTabUp: "Move tab up",
|
||||||
|
moveTabDown: "Move tab down",
|
||||||
|
closePane: "Close pane",
|
||||||
|
togglePaneMaximization: "Toggle pane maximization",
|
||||||
|
toggleFocusMode: "Toggle focus mode",
|
||||||
|
toggleSidePanel: "Toggle side panel",
|
||||||
},
|
},
|
||||||
icons: {},
|
icons: {},
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||||
|
activeTabKind: null,
|
||||||
|
activeTabIndex: -1,
|
||||||
|
activeTabCount: 0,
|
||||||
dispatch: (action) => dispatched.push(action),
|
dispatch: (action) => dispatched.push(action),
|
||||||
runGitAction: (action) => runGitActions.push(action),
|
runGitAction: (action) => runGitActions.push(action),
|
||||||
},
|
},
|
||||||
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
expect(
|
||||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
contributions
|
||||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
.filter((item) =>
|
||||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
[
|
||||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
"tab:new-agent",
|
||||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
"git:commit",
|
||||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
"tab:new-terminal",
|
||||||
|
"tab:new-browser",
|
||||||
|
"pane:split-right",
|
||||||
|
"pane:split-down",
|
||||||
|
].includes(item.id),
|
||||||
|
)
|
||||||
|
.map(({ id, visibility }) => ({ id, visibility })),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "tab:new-agent", visibility: "always" },
|
||||||
|
{ id: "git:commit", visibility: "always" },
|
||||||
|
{ id: "tab:new-terminal", visibility: "query" },
|
||||||
|
{ id: "tab:new-browser", visibility: "query" },
|
||||||
|
{ id: "pane:split-right", visibility: "query" },
|
||||||
|
{ id: "pane:split-down", visibility: "query" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||||
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
|
fixture.value.capabilities = {
|
||||||
|
canSplitPanes: false,
|
||||||
|
canOpenBrowserTabs: false,
|
||||||
|
isGit: false,
|
||||||
|
};
|
||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||||
|
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||||
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
for (const contribution of contributions) contribution.run();
|
for (const contribution of contributions) contribution.run();
|
||||||
|
|
||||||
expect(fixture.dispatched).toEqual([
|
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||||
{ id: "workspace.agent.new", scope: "workspace" },
|
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||||
{ id: "workspace.terminal.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.browser.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.right", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.down", scope: "workspace" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps workspace creation commands available outside Git", () => {
|
it("keeps workspace creation commands available outside Git", () => {
|
||||||
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual([
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
"tab:new-agent",
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
"tab:new-terminal",
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
|
||||||
"tab:new-browser",
|
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
|
||||||
"pane:split-right",
|
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||||
"pane:split-down",
|
|
||||||
]);
|
|
||||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { GitAction, GitActions } from "@/git/policy";
|
import type { GitAction, GitActions } from "@/git/policy";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type {
|
||||||
|
WorkspacePanelPlacement,
|
||||||
|
WorkspacePanelTarget,
|
||||||
|
} from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||||
|
|
||||||
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
|
|||||||
newBrowser: string;
|
newBrowser: string;
|
||||||
splitRight: string;
|
splitRight: string;
|
||||||
splitDown: string;
|
splitDown: string;
|
||||||
|
changes: string;
|
||||||
|
files: string;
|
||||||
|
pullRequest: string;
|
||||||
|
openPanel(name: string, placement: WorkspacePanelPlacement): string;
|
||||||
|
previousTab: string;
|
||||||
|
nextTab: string;
|
||||||
|
closeCurrentTab: string;
|
||||||
|
renameTab: string;
|
||||||
|
reloadAgent: string;
|
||||||
|
copyResumeCommand: string;
|
||||||
|
copyAgentId: string;
|
||||||
|
copyTerminalId: string;
|
||||||
|
copyFilePath: string;
|
||||||
|
closeTabsLeft: string;
|
||||||
|
closeTabsRight: string;
|
||||||
|
closeOtherTabs: string;
|
||||||
|
focusPaneLeft: string;
|
||||||
|
focusPaneRight: string;
|
||||||
|
focusPaneUp: string;
|
||||||
|
focusPaneDown: string;
|
||||||
|
moveTabLeft: string;
|
||||||
|
moveTabRight: string;
|
||||||
|
moveTabUp: string;
|
||||||
|
moveTabDown: string;
|
||||||
|
closePane: string;
|
||||||
|
togglePaneMaximization: string;
|
||||||
|
toggleFocusMode: string;
|
||||||
|
toggleSidePanel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterIcons {
|
export interface WorkspaceCommandCenterIcons {
|
||||||
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
|
|||||||
newBrowser?: CommandCenterIcon;
|
newBrowser?: CommandCenterIcon;
|
||||||
splitRight?: CommandCenterIcon;
|
splitRight?: CommandCenterIcon;
|
||||||
splitDown?: CommandCenterIcon;
|
splitDown?: CommandCenterIcon;
|
||||||
|
changes?: CommandCenterIcon;
|
||||||
|
files?: CommandCenterIcon;
|
||||||
|
pullRequest?: CommandCenterIcon;
|
||||||
|
previousTab?: CommandCenterIcon;
|
||||||
|
nextTab?: CommandCenterIcon;
|
||||||
|
close?: CommandCenterIcon;
|
||||||
|
rename?: CommandCenterIcon;
|
||||||
|
reload?: CommandCenterIcon;
|
||||||
|
copy?: CommandCenterIcon;
|
||||||
|
focusPane?: CommandCenterIcon;
|
||||||
|
moveTab?: CommandCenterIcon;
|
||||||
|
maximize?: CommandCenterIcon;
|
||||||
|
focusMode?: CommandCenterIcon;
|
||||||
|
sidePanel?: CommandCenterIcon;
|
||||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
|
|||||||
splitRight?: ShortcutKey[][];
|
splitRight?: ShortcutKey[][];
|
||||||
splitDown?: ShortcutKey[][];
|
splitDown?: ShortcutKey[][];
|
||||||
archiveWorkspace?: ShortcutKey[][];
|
archiveWorkspace?: ShortcutKey[][];
|
||||||
|
previousTab?: ShortcutKey[][];
|
||||||
|
nextTab?: ShortcutKey[][];
|
||||||
|
closeCurrentTab?: ShortcutKey[][];
|
||||||
|
closePane?: ShortcutKey[][];
|
||||||
|
togglePaneMaximization?: ShortcutKey[][];
|
||||||
|
toggleFocusMode?: ShortcutKey[][];
|
||||||
|
toggleSidePanel?: ShortcutKey[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterSource {
|
export interface WorkspaceCommandCenterSource {
|
||||||
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: boolean;
|
canSplitPanes: boolean;
|
||||||
canOpenBrowserTabs: boolean;
|
canOpenBrowserTabs: boolean;
|
||||||
|
isGit: boolean;
|
||||||
};
|
};
|
||||||
|
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||||
|
activeTabIndex: number;
|
||||||
|
activeTabCount: number;
|
||||||
dispatch(action: KeyboardActionDefinition): void;
|
dispatch(action: KeyboardActionDefinition): void;
|
||||||
runGitAction(action: GitAction): void;
|
runGitAction(action: GitAction): void;
|
||||||
}
|
}
|
||||||
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkspaceCommandCenterContributions(
|
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
|
||||||
|
|
||||||
|
function buildQueryAction(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
input: QueryActionInput,
|
||||||
|
): CommandCenterContribution {
|
||||||
|
return buildWorkspaceAction({ ...input, source, visibility: "query" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanelContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
const contributions: CommandCenterContribution[] = [];
|
||||||
|
const panelTargets: Array<{
|
||||||
|
target: WorkspacePanelTarget;
|
||||||
|
name: string;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
}> = [
|
||||||
|
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
|
||||||
|
{ target: "files", name: source.labels.files, icon: source.icons.files },
|
||||||
|
{
|
||||||
|
target: "pull-request",
|
||||||
|
name: source.labels.pullRequest,
|
||||||
|
icon: source.icons.pullRequest,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, panel] of panelTargets.entries()) {
|
||||||
|
if (panel.target !== "files" && !source.capabilities.isGit) continue;
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}`,
|
||||||
|
rank: 4 + index,
|
||||||
|
title: source.labels.openPanel(panel.name, "supporting"),
|
||||||
|
keywords: ["open", panel.target, "tab", "supporting"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement: "supporting",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!source.capabilities.canSplitPanes) continue;
|
||||||
|
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}:${placement}`,
|
||||||
|
rank: 10 + index * 2 + placementIndex,
|
||||||
|
title: source.labels.openPanel(panel.name, placement),
|
||||||
|
keywords: ["open", panel.target, "tab", placement, "pane"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveTabContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
if (source.activeTabCount === 0) return [];
|
||||||
|
const contributions: CommandCenterContribution[] = [
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:previous",
|
||||||
|
rank: 30,
|
||||||
|
title: source.labels.previousTab,
|
||||||
|
keywords: ["tab", "previous", "switch", "navigate"],
|
||||||
|
icon: source.icons.previousTab,
|
||||||
|
shortcutKeys: source.shortcuts.previousTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:next",
|
||||||
|
rank: 31,
|
||||||
|
title: source.labels.nextTab,
|
||||||
|
keywords: ["tab", "next", "switch", "navigate"],
|
||||||
|
icon: source.icons.nextTab,
|
||||||
|
shortcutKeys: source.shortcuts.nextTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-current",
|
||||||
|
rank: 32,
|
||||||
|
title: source.labels.closeCurrentTab,
|
||||||
|
keywords: ["tab", "close", "current"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closeCurrentTab,
|
||||||
|
action: { id: "workspace.tab.close-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:rename-current",
|
||||||
|
rank: 33,
|
||||||
|
title: source.labels.renameTab,
|
||||||
|
keywords: ["tab", "rename", "current"],
|
||||||
|
icon: source.icons.rename,
|
||||||
|
action: { id: "workspace.tab.rename-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-id",
|
||||||
|
rank: 34,
|
||||||
|
title:
|
||||||
|
source.activeTabKind === "agent"
|
||||||
|
? source.labels.copyAgentId
|
||||||
|
: source.labels.copyTerminalId,
|
||||||
|
keywords: ["tab", "copy", "id"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-id", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "agent") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:reload-current",
|
||||||
|
rank: 35,
|
||||||
|
title: source.labels.reloadAgent,
|
||||||
|
keywords: ["tab", "agent", "reload"],
|
||||||
|
icon: source.icons.reload,
|
||||||
|
action: { id: "workspace.tab.reload-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-resume-command",
|
||||||
|
rank: 36,
|
||||||
|
title: source.labels.copyResumeCommand,
|
||||||
|
keywords: ["tab", "agent", "copy", "resume", "command"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "file") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-file-path",
|
||||||
|
rank: 37,
|
||||||
|
title: source.labels.copyFilePath,
|
||||||
|
keywords: ["tab", "file", "copy", "path"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex > 0) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-left",
|
||||||
|
rank: 38,
|
||||||
|
title: source.labels.closeTabsLeft,
|
||||||
|
keywords: ["tab", "close", "left"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-left", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-right",
|
||||||
|
rank: 39,
|
||||||
|
title: source.labels.closeTabsRight,
|
||||||
|
keywords: ["tab", "close", "right"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-right", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabCount > 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-others",
|
||||||
|
rank: 40,
|
||||||
|
title: source.labels.closeOtherTabs,
|
||||||
|
keywords: ["tab", "close", "others"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-others", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
|
||||||
|
const paneActions: Array<{
|
||||||
|
id: string;
|
||||||
|
rank: number;
|
||||||
|
title: string;
|
||||||
|
keywords: string[];
|
||||||
|
action: KeyboardActionDefinition;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
shortcutKeys?: ShortcutKey[][];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "pane:split-right",
|
||||||
|
rank: 50,
|
||||||
|
title: source.labels.splitRight,
|
||||||
|
keywords: ["split", "pane", "vertical"],
|
||||||
|
icon: source.icons.splitRight,
|
||||||
|
shortcutKeys: source.shortcuts.splitRight,
|
||||||
|
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:split-down",
|
||||||
|
rank: 51,
|
||||||
|
title: source.labels.splitDown,
|
||||||
|
keywords: ["split", "pane", "horizontal"],
|
||||||
|
icon: source.icons.splitDown,
|
||||||
|
shortcutKeys: source.shortcuts.splitDown,
|
||||||
|
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-left",
|
||||||
|
rank: 52,
|
||||||
|
title: source.labels.focusPaneLeft,
|
||||||
|
keywords: ["pane", "focus", "left"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-right",
|
||||||
|
rank: 53,
|
||||||
|
title: source.labels.focusPaneRight,
|
||||||
|
keywords: ["pane", "focus", "right"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-up",
|
||||||
|
rank: 54,
|
||||||
|
title: source.labels.focusPaneUp,
|
||||||
|
keywords: ["pane", "focus", "up"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-down",
|
||||||
|
rank: 55,
|
||||||
|
title: source.labels.focusPaneDown,
|
||||||
|
keywords: ["pane", "focus", "down"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-left",
|
||||||
|
rank: 56,
|
||||||
|
title: source.labels.moveTabLeft,
|
||||||
|
keywords: ["pane", "tab", "move", "left"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-right",
|
||||||
|
rank: 57,
|
||||||
|
title: source.labels.moveTabRight,
|
||||||
|
keywords: ["pane", "tab", "move", "right"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-up",
|
||||||
|
rank: 58,
|
||||||
|
title: source.labels.moveTabUp,
|
||||||
|
keywords: ["pane", "tab", "move", "up"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-down",
|
||||||
|
rank: 59,
|
||||||
|
title: source.labels.moveTabDown,
|
||||||
|
keywords: ["pane", "tab", "move", "down"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:close",
|
||||||
|
rank: 60,
|
||||||
|
title: source.labels.closePane,
|
||||||
|
keywords: ["pane", "close"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closePane,
|
||||||
|
action: { id: "workspace.pane.close", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:maximize-toggle",
|
||||||
|
rank: 61,
|
||||||
|
title: source.labels.togglePaneMaximization,
|
||||||
|
keywords: ["pane", "maximize", "restore", "toggle"],
|
||||||
|
icon: source.icons.maximize,
|
||||||
|
shortcutKeys: source.shortcuts.togglePaneMaximization,
|
||||||
|
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-mode-toggle",
|
||||||
|
rank: 62,
|
||||||
|
title: source.labels.toggleFocusMode,
|
||||||
|
keywords: ["pane", "focus", "mode", "toggle"],
|
||||||
|
icon: source.icons.focusMode,
|
||||||
|
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||||
|
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return paneActions.map((action) => buildQueryAction(source, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreationContributions(
|
||||||
source: WorkspaceCommandCenterSource,
|
source: WorkspaceCommandCenterSource,
|
||||||
): CommandCenterContribution[] {
|
): CommandCenterContribution[] {
|
||||||
const contributions: CommandCenterContribution[] = [
|
const contributions: CommandCenterContribution[] = [
|
||||||
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
const primary = source.gitActions.primary;
|
const primary = source.gitActions.primary;
|
||||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-terminal",
|
id: "tab:new-terminal",
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: source.labels.newTerminal,
|
title: source.labels.newTerminal,
|
||||||
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
icon: source.icons.newTerminal,
|
icon: source.icons.newTerminal,
|
||||||
shortcutKeys: source.shortcuts.newTerminal,
|
shortcutKeys: source.shortcuts.newTerminal,
|
||||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (source.capabilities.canOpenBrowserTabs) {
|
if (source.capabilities.canOpenBrowserTabs) {
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-browser",
|
id: "tab:new-browser",
|
||||||
rank: 3,
|
rank: 3,
|
||||||
title: source.labels.newBrowser,
|
title: source.labels.newBrowser,
|
||||||
keywords: ["browser", "web", "preview"],
|
keywords: ["browser", "web", "preview"],
|
||||||
icon: source.icons.newBrowser,
|
icon: source.icons.newBrowser,
|
||||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source.capabilities.canSplitPanes) {
|
return contributions;
|
||||||
contributions.push(
|
}
|
||||||
buildWorkspaceAction({
|
|
||||||
source,
|
export function buildWorkspaceCommandCenterContributions(
|
||||||
id: "pane:split-right",
|
source: WorkspaceCommandCenterSource,
|
||||||
rank: 4,
|
): CommandCenterContribution[] {
|
||||||
title: source.labels.splitRight,
|
const contributions = [
|
||||||
keywords: ["split", "pane", "vertical"],
|
...buildCreationContributions(source),
|
||||||
icon: source.icons.splitRight,
|
...buildPanelContributions(source),
|
||||||
shortcutKeys: source.shortcuts.splitRight,
|
...buildActiveTabContributions(source),
|
||||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||||
visibility: "query",
|
buildQueryAction(source, {
|
||||||
|
id: "side-panel:toggle",
|
||||||
|
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" },
|
||||||
}),
|
}),
|
||||||
buildWorkspaceAction({
|
];
|
||||||
source,
|
const primary = source.gitActions.primary;
|
||||||
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",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||||
if (action.id === primary?.id) continue;
|
if (action.id === primary?.id) continue;
|
||||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||||
}
|
}
|
||||||
return contributions;
|
return contributions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Columns2,
|
||||||
|
Copy,
|
||||||
|
Files,
|
||||||
|
Focus,
|
||||||
|
GitCompareArrows,
|
||||||
|
GitPullRequest,
|
||||||
|
Globe,
|
||||||
|
Maximize2,
|
||||||
|
Move,
|
||||||
|
PanelRight,
|
||||||
|
Pencil,
|
||||||
|
RotateCw,
|
||||||
|
Rows2,
|
||||||
|
SquarePen,
|
||||||
|
SquareTerminal,
|
||||||
|
X,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { getIsElectron } from "@/constants/platform";
|
import { getIsElectron } from "@/constants/platform";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
@@ -13,6 +33,12 @@ import {
|
|||||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import {
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getCommandCenterIcon } from "./icon";
|
import { getCommandCenterIcon } from "./icon";
|
||||||
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
|||||||
newBrowser: getCommandCenterIcon(Globe),
|
newBrowser: getCommandCenterIcon(Globe),
|
||||||
splitRight: getCommandCenterIcon(Columns2),
|
splitRight: getCommandCenterIcon(Columns2),
|
||||||
splitDown: getCommandCenterIcon(Rows2),
|
splitDown: getCommandCenterIcon(Rows2),
|
||||||
|
changes: getCommandCenterIcon(GitCompareArrows),
|
||||||
|
files: getCommandCenterIcon(Files),
|
||||||
|
pullRequest: getCommandCenterIcon(GitPullRequest),
|
||||||
|
previousTab: getCommandCenterIcon(ArrowLeft),
|
||||||
|
nextTab: getCommandCenterIcon(ArrowRight),
|
||||||
|
close: getCommandCenterIcon(X),
|
||||||
|
rename: getCommandCenterIcon(Pencil),
|
||||||
|
reload: getCommandCenterIcon(RotateCw),
|
||||||
|
copy: getCommandCenterIcon(Copy),
|
||||||
|
focusPane: getCommandCenterIcon(Focus),
|
||||||
|
moveTab: getCommandCenterIcon(Move),
|
||||||
|
maximize: getCommandCenterIcon(Maximize2),
|
||||||
|
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||||
|
sidePanel: getCommandCenterIcon(PanelRight),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPEN_PANEL_LABEL_KEYS = {
|
||||||
|
supporting: "shell.commandCenter.open",
|
||||||
|
"side-panel": "shell.commandCenter.openInSidePanel",
|
||||||
|
"focused-pane": "shell.commandCenter.openInFocusedPane",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
function StaticIcon() {
|
function StaticIcon() {
|
||||||
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
|
|||||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||||
archiveWorkspace:
|
archiveWorkspace:
|
||||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||||
|
previousTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
|
||||||
|
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
|
||||||
|
closeCurrentTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||||
|
closePane:
|
||||||
|
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||||
|
togglePaneMaximization:
|
||||||
|
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
|
||||||
|
toggleSidePanel:
|
||||||
|
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +117,22 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? null;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
|
const workspaceKey =
|
||||||
|
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
|
||||||
|
const layout = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
|
||||||
|
);
|
||||||
|
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
|
||||||
|
const focusedTabs = layout
|
||||||
|
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
|
||||||
|
: [];
|
||||||
|
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||||
|
const activeTabKind =
|
||||||
|
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const { gitActions } = useGitActions({
|
const { gitActions, isGit } = useGitActions({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
cwd: cwd ?? "",
|
cwd: cwd ?? "",
|
||||||
icons: GIT_ACTION_ICONS,
|
icons: GIT_ACTION_ICONS,
|
||||||
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
splitDown: t("workspace.tabs.actions.splitDown"),
|
||||||
|
changes: t("workspace.tabs.actions.changes"),
|
||||||
|
files: t("workspace.tabs.actions.files"),
|
||||||
|
pullRequest: t("workspace.tabs.actions.pullRequest"),
|
||||||
|
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
|
||||||
|
previousTab: t("settings.shortcuts.help.previousTab"),
|
||||||
|
nextTab: t("settings.shortcuts.help.nextTab"),
|
||||||
|
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
|
||||||
|
renameTab: t("workspace.tabs.menu.rename"),
|
||||||
|
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
|
||||||
|
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
|
||||||
|
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
|
||||||
|
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
|
||||||
|
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
|
||||||
|
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
|
||||||
|
closeTabsRight: t("workspace.tabs.menu.closeRight"),
|
||||||
|
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
|
||||||
|
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
|
||||||
|
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
|
||||||
|
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
|
||||||
|
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
|
||||||
|
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
|
||||||
|
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
|
||||||
|
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
|
||||||
|
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
|
||||||
|
closePane: t("settings.shortcuts.help.closePane"),
|
||||||
|
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
|
||||||
|
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||||
|
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||||
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||||
canOpenBrowserTabs: getIsElectron(),
|
canOpenBrowserTabs: getIsElectron(),
|
||||||
|
isGit,
|
||||||
},
|
},
|
||||||
|
activeTabKind,
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabCount: focusedTabs.length,
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
keyboardActionDispatcher.dispatch(action);
|
keyboardActionDispatcher.dispatch(action);
|
||||||
},
|
},
|
||||||
runGitAction,
|
runGitAction,
|
||||||
}),
|
}),
|
||||||
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
|
[
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabKind,
|
||||||
|
focusedTabs.length,
|
||||||
|
gitActions,
|
||||||
|
isCompact,
|
||||||
|
isGit,
|
||||||
|
keyboardActionDispatcher,
|
||||||
|
overrides,
|
||||||
|
runGitAction,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useCommandCenterActions({
|
useCommandCenterActions({
|
||||||
|
|||||||
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
},
|
||||||
desktopSplit: {
|
desktopSplit: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ interface SplitContainerProps {
|
|||||||
renderPaneEmptyState?: (input: {
|
renderPaneEmptyState?: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) => ReactNode;
|
}) => ReactNode;
|
||||||
focusModeEnabled?: boolean;
|
focusModeEnabled?: boolean;
|
||||||
@@ -1289,6 +1290,7 @@ function SplitPaneView({
|
|||||||
: (renderPaneEmptyState?.({
|
: (renderPaneEmptyState?.({
|
||||||
paneId,
|
paneId,
|
||||||
paneIsolated,
|
paneIsolated,
|
||||||
|
isFocused: isFocused && isWorkspaceFocused,
|
||||||
onClosePane: handleClosePane,
|
onClosePane: handleClosePane,
|
||||||
}) ?? null)}
|
}) ?? null)}
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
|
|||||||
@@ -86,23 +86,28 @@ describe("control geometry", () => {
|
|||||||
expect(geometry.formTextInputMd.lineHeight).toBe(20);
|
expect(geometry.formTextInputMd.lineHeight).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives field padding from line height without changing the control height", () => {
|
it("derives field padding from content and border without changing the control height", () => {
|
||||||
const geometry = createControlGeometry(theme);
|
const geometry = createControlGeometry(theme);
|
||||||
|
const borderWidth = theme.borderWidth[1];
|
||||||
|
|
||||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||||
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
|
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
|
||||||
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
|
expect(
|
||||||
geometry.fieldControlSm.minHeight,
|
geometry.fieldTextSm.lineHeight +
|
||||||
);
|
geometry.fieldControlSm.paddingVertical * 2 +
|
||||||
|
borderWidth * 2,
|
||||||
|
).toBe(geometry.fieldControlSm.minHeight);
|
||||||
|
|
||||||
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
||||||
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
|
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
|
||||||
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
|
expect(
|
||||||
geometry.fieldControlMd.minHeight,
|
geometry.fieldTextMd.lineHeight +
|
||||||
);
|
geometry.fieldControlMd.paddingVertical * 2 +
|
||||||
|
borderWidth * 2,
|
||||||
|
).toBe(geometry.fieldControlMd.minHeight);
|
||||||
|
|
||||||
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
|
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
|
||||||
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
|
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
|
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
|
||||||
|
|||||||
@@ -78,8 +78,12 @@ function fieldLineHeight(fontSize: number): number {
|
|||||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
|
function fieldVerticalPadding(
|
||||||
return (controlHeight - lineHeight) / 2;
|
controlHeight: number,
|
||||||
|
lineHeight: number,
|
||||||
|
borderWidth: number,
|
||||||
|
): number {
|
||||||
|
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getControlInteractionPhase(
|
export function getControlInteractionPhase(
|
||||||
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createControlGeometry(theme: Theme) {
|
export function createControlGeometry(theme: Theme) {
|
||||||
|
const controlBorderWidth = theme.borderWidth[1];
|
||||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||||
const fieldControlSm = {
|
const fieldControlSm = {
|
||||||
minHeight: CONTROL_HEIGHTS.compact,
|
minHeight: CONTROL_HEIGHTS.compact,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
|
paddingVertical: fieldVerticalPadding(
|
||||||
|
CONTROL_HEIGHTS.compact,
|
||||||
|
fieldTextSmLineHeight,
|
||||||
|
controlBorderWidth,
|
||||||
|
),
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
};
|
};
|
||||||
const fieldControlMd = {
|
const fieldControlMd = {
|
||||||
minHeight: CONTROL_HEIGHTS.field,
|
minHeight: CONTROL_HEIGHTS.field,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
|
paddingVertical: fieldVerticalPadding(
|
||||||
|
CONTROL_HEIGHTS.field,
|
||||||
|
fieldTextMdLineHeight,
|
||||||
|
controlBorderWidth,
|
||||||
|
),
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
};
|
};
|
||||||
const fieldTextSm = {
|
const fieldTextSm = {
|
||||||
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fieldTextSm,
|
fieldTextSm,
|
||||||
fieldTextMd,
|
fieldTextMd,
|
||||||
controlRest: {
|
controlRest: {
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: controlBorderWidth,
|
||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
outlineWidth: 0,
|
outlineWidth: 0,
|
||||||
outlineColor: "transparent",
|
outlineColor: "transparent",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
|
|||||||
}
|
}
|
||||||
if (hint) {
|
if (hint) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
|
<Text style={styles.hintText} testID={hintTestID}>
|
||||||
{hint}
|
{hint}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||||
|
import { useWorkspaceHasDiffStat } from "./workspace-diff-stat";
|
||||||
|
|
||||||
|
const SERVER_ID = "diff-stat-pill";
|
||||||
|
const WORKSPACE_ID = "workspace";
|
||||||
|
|
||||||
|
const workspace: WorkspaceDescriptor = {
|
||||||
|
id: WORKSPACE_ID,
|
||||||
|
projectId: "project",
|
||||||
|
projectDisplayName: "Project",
|
||||||
|
projectRootPath: "/repo",
|
||||||
|
workspaceDirectory: "/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function setDiffStat(diffStat: WorkspaceDescriptor["diffStat"]): void {
|
||||||
|
useSessionStore.getState().setWorkspaces(SERVER_ID, (workspaces) => {
|
||||||
|
const next = new Map(workspaces);
|
||||||
|
next.set(WORKSPACE_ID, { ...workspace, diffStat });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useWorkspaceHasDiffStat", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
|
seedSessionWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render its owner when only the visible counts change", () => {
|
||||||
|
let renderCount = 0;
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
renderCount += 1;
|
||||||
|
return useWorkspaceHasDiffStat(SERVER_ID, WORKSPACE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
expect(renderCount).toBe(1);
|
||||||
|
|
||||||
|
act(() => setDiffStat({ additions: 2, deletions: 0 }));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
expect(renderCount).toBe(2);
|
||||||
|
|
||||||
|
act(() => setDiffStat({ additions: 5, deletions: 3 }));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
expect(renderCount).toBe(2);
|
||||||
|
|
||||||
|
act(() => setDiffStat(null));
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
expect(renderCount).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
|
import { Pressable } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
|
import { composerPillStyles } from "@/composer/pill-styles";
|
||||||
|
import { useVisibleWorkspaceDiffStat } from "@/composer/workspace-diff-stat";
|
||||||
|
|
||||||
|
interface ComposerDiffStatPillProps {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposerDiffStatPill({ additions, deletions, onPress }: ComposerDiffStatPillProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||||
|
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||||
|
const bodyStyle = useMemo(
|
||||||
|
() => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
|
||||||
|
[isHovered],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
testID="composer-diff-stat-pill"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("workspace.git.diff.openChangesTab")}
|
||||||
|
onPress={onPress}
|
||||||
|
onHoverIn={handleHoverIn}
|
||||||
|
onHoverOut={handleHoverOut}
|
||||||
|
style={bodyStyle}
|
||||||
|
>
|
||||||
|
<DiffStat additions={additions} deletions={deletions} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceDiffStatPill = memo(function WorkspaceDiffStatPill({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}): ReactElement | null {
|
||||||
|
const diffStat = useVisibleWorkspaceDiffStat(serverId, workspaceId);
|
||||||
|
if (!diffStat) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ComposerDiffStatPill
|
||||||
|
additions={diffStat.additions}
|
||||||
|
deletions={diffStat.deletions}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
|
||||||
|
|
||||||
|
describe("composer input height style", () => {
|
||||||
|
it("applies the measured height alongside its bounds", () => {
|
||||||
|
expect(
|
||||||
|
resolveComposerInputHeightStyle({
|
||||||
|
inputHeight: 96,
|
||||||
|
minInputHeight: 30,
|
||||||
|
maxInputHeight: 240,
|
||||||
|
applyMeasuredHeight: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
height: 96,
|
||||||
|
minHeight: 30,
|
||||||
|
maxHeight: 240,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a native multiline input expose its intrinsic content height", () => {
|
||||||
|
expect(
|
||||||
|
resolveComposerInputHeightStyle({
|
||||||
|
inputHeight: 96,
|
||||||
|
minInputHeight: 30,
|
||||||
|
maxInputHeight: 240,
|
||||||
|
applyMeasuredHeight: false,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
minHeight: 30,
|
||||||
|
maxHeight: 240,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps native measurement unconstrained until the composer reaches its cap", () => {
|
||||||
|
expect(shouldScrollComposerInput({ inputHeight: 30, maxInputHeight: 240 })).toBe(false);
|
||||||
|
expect(shouldScrollComposerInput({ inputHeight: 240, maxInputHeight: 240 })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
interface ComposerInputHeightStyleInput {
|
||||||
|
inputHeight: number;
|
||||||
|
minInputHeight: number;
|
||||||
|
maxInputHeight: number;
|
||||||
|
applyMeasuredHeight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveComposerInputHeightStyle(input: ComposerInputHeightStyleInput) {
|
||||||
|
// Native must remain intrinsically sized below the cap so
|
||||||
|
// onContentSizeChange can observe growth. Web gets its height from the DOM
|
||||||
|
// mirror and therefore needs that measurement applied explicitly.
|
||||||
|
return {
|
||||||
|
...(input.applyMeasuredHeight ? { height: input.inputHeight } : {}),
|
||||||
|
minHeight: input.minInputHeight,
|
||||||
|
maxHeight: input.maxInputHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldScrollComposerInput(input: {
|
||||||
|
inputHeight: number;
|
||||||
|
maxInputHeight: number;
|
||||||
|
}): boolean {
|
||||||
|
return input.inputHeight >= input.maxInputHeight;
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { useComposerHeightMirror } from "./height-mirror";
|
import { useComposerHeightMirror } from "./height-mirror";
|
||||||
|
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
|
||||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||||
import {
|
import {
|
||||||
@@ -990,20 +991,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
|
|||||||
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
|
|
||||||
if (isWeb) {
|
|
||||||
return {
|
|
||||||
height: inputHeight,
|
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
|
||||||
maxHeight: maxInputHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
|
||||||
maxHeight: maxInputHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||||
}
|
}
|
||||||
@@ -1764,7 +1751,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
() => [
|
() => [
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
mode.isMonospace && styles.textInputMonospace,
|
mode.isMonospace && styles.textInputMonospace,
|
||||||
computeTextInputHeightStyle(inputHeight, maxInputHeight),
|
resolveComposerInputHeightStyle({
|
||||||
|
inputHeight,
|
||||||
|
minInputHeight: MIN_INPUT_HEIGHT,
|
||||||
|
maxInputHeight,
|
||||||
|
applyMeasuredHeight: isWeb,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
[inputHeight, maxInputHeight, mode.isMonospace],
|
[inputHeight, maxInputHeight, mode.isMonospace],
|
||||||
);
|
);
|
||||||
@@ -1838,7 +1830,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
|
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
|
||||||
|
|
||||||
|
export function useVisibleWorkspaceDiffStat(
|
||||||
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): DiffStat | null {
|
||||||
|
const diffStat = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.diffStat);
|
||||||
|
return hasDiffStatChanges(diffStat) ? diffStat : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceHasDiffStat(serverId: string, workspaceId: string): boolean {
|
||||||
|
return (
|
||||||
|
useWorkspaceFields(serverId, workspaceId, (workspace) =>
|
||||||
|
hasDiffStatChanges(workspace.diffStat),
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDiffStatChanges(diffStat: WorkspaceDescriptor["diffStat"]): diffStat is DiffStat {
|
||||||
|
return Boolean(diffStat && (diffStat.additions > 0 || diffStat.deletions > 0));
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { LiveFileModel, type LiveFileSession } from "./model";
|
import { LiveFileModel, type LiveFileSession } from "./model";
|
||||||
|
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export function useLiveFile(input: {
|
export function useLiveFile(input: {
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
|
|||||||
return client.subscribeFile(target, onVersion);
|
return client.subscribeFile(target, onVersion);
|
||||||
},
|
},
|
||||||
read(target) {
|
read(target) {
|
||||||
return client.readFile(target.cwd, target.path);
|
return client.readFile(
|
||||||
|
target.cwd,
|
||||||
|
target.path,
|
||||||
|
undefined,
|
||||||
|
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [input.client]);
|
}, [input.client]);
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
|
||||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
||||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { getFileNameFromPath } from "@/attachments/utils";
|
import { getFileNameFromPath } from "@/attachments/utils";
|
||||||
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
|||||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||||
import { createFileObservationSource } from "./editor/observation-source";
|
import { createFileObservationSource } from "./editor/observation-source";
|
||||||
import { FileEditorView } from "./editor/view";
|
import { FileEditorView } from "./editor/view";
|
||||||
|
import { FileSourceView } from "./source/view";
|
||||||
import type { FileConflictAlertState } from "./conflict-alert";
|
import type { FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { LiveFileModel } from "./live-file/model";
|
import type { LiveFileModel } from "./live-file/model";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CodeLineProps {
|
|
||||||
tokens: HighlightToken[];
|
|
||||||
lineNumber: number;
|
|
||||||
gutterWidth: number;
|
|
||||||
highlighted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
mode?: "preview" | "source";
|
mode?: "preview" | "source";
|
||||||
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileLineSelection {
|
|
||||||
lineStart: number;
|
|
||||||
lineEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
return `${size} B`;
|
||||||
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampLineSelection(input: {
|
function ReadonlySource({
|
||||||
lineStart?: number;
|
preview,
|
||||||
lineEnd?: number;
|
filename,
|
||||||
lineCount: number;
|
location,
|
||||||
}): FileLineSelection | null {
|
navigationRevision,
|
||||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
}: {
|
||||||
return null;
|
preview: ExplorerFile;
|
||||||
}
|
filename: string;
|
||||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
location: WorkspaceFileLocation;
|
||||||
const rawLineEnd =
|
navigationRevision: number;
|
||||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
}) {
|
||||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
const theme = UnistylesRuntime.getTheme();
|
||||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
const { t } = useTranslation();
|
||||||
}
|
const visualTheme = useMemo(
|
||||||
|
() => ({
|
||||||
const CodeLine = React.memo(function CodeLine({
|
colorScheme: theme.colorScheme,
|
||||||
tokens,
|
background: theme.colors.surface0,
|
||||||
lineNumber,
|
foreground: theme.colors.foreground,
|
||||||
gutterWidth,
|
cursor: theme.colors.terminal.cursor,
|
||||||
highlighted,
|
foregroundMuted: theme.colors.foregroundMuted,
|
||||||
}: CodeLineProps) {
|
border: theme.colors.border,
|
||||||
const gutterStyle = useMemo(
|
selection: theme.colors.terminal.selectionBackground,
|
||||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
monoFont: theme.fontFamily.mono,
|
||||||
[gutterWidth],
|
codeFontSize: theme.fontSize.code,
|
||||||
);
|
syntax: theme.colors.syntax,
|
||||||
const lineStyle = useMemo(
|
}),
|
||||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
[theme],
|
||||||
[highlighted],
|
|
||||||
);
|
|
||||||
const keyedTokens = useMemo(
|
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
||||||
[tokens],
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={lineStyle}>
|
<FileSourceView
|
||||||
<View style={gutterStyle}>
|
content={preview.content ?? ""}
|
||||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
filename={filename}
|
||||||
{String(lineNumber)}
|
location={location}
|
||||||
</Text>
|
navigationRevision={navigationRevision}
|
||||||
</View>
|
size={preview.size}
|
||||||
<Text selectable style={codeLineStyles.lineText}>
|
theme={visualTheme}
|
||||||
{keyedTokens.map(({ key, token }) => (
|
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||||
<CodeLineToken key={key} token={token} />
|
/>
|
||||||
))}
|
);
|
||||||
</Text>
|
}
|
||||||
|
|
||||||
|
function TooLargeSource({ size }: { size?: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View style={styles.centerState} testID="file-source-too-large">
|
||||||
|
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||||
|
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
interface CodeLineTokenProps {
|
|
||||||
token: HighlightToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
|
||||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
|
||||||
line: {
|
|
||||||
flexDirection: "row",
|
|
||||||
},
|
|
||||||
highlightedLine: {
|
|
||||||
backgroundColor: theme.colors.accentBorder,
|
|
||||||
},
|
|
||||||
gutter: {
|
|
||||||
alignItems: "flex-end",
|
|
||||||
paddingRight: theme.spacing[3],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
gutterText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
fontSize: theme.fontSize.code,
|
|
||||||
lineHeight: theme.fontSize.code * 1.45,
|
|
||||||
opacity: 0.4,
|
|
||||||
userSelect: "none",
|
|
||||||
},
|
|
||||||
lineText: {
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
fontSize: theme.fontSize.code,
|
|
||||||
lineHeight: theme.fontSize.code * 1.45,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function FilePreviewBody({
|
function FilePreviewBody({
|
||||||
preview,
|
preview,
|
||||||
mode,
|
mode,
|
||||||
isLoading,
|
isLoading,
|
||||||
isMobile,
|
isMobile: _isMobile,
|
||||||
location,
|
location,
|
||||||
navigationRevision,
|
navigationRevision,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const theme = UnistylesRuntime.getTheme();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
// A line target means the caller wants to land on that line, so fall back to
|
// A line target means the caller wants to land on that line, so fall back to
|
||||||
@@ -197,48 +146,11 @@ function FilePreviewBody({
|
|||||||
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
|
||||||
if (!preview || preview.kind !== "text" || renderKind) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return highlightCode(preview.content ?? "", filePath);
|
|
||||||
}, [renderKind, preview, filePath]);
|
|
||||||
|
|
||||||
const gutterWidth = useMemo(() => {
|
|
||||||
if (!highlightedLines) return 0;
|
|
||||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
|
||||||
}, [highlightedLines, theme.fontSize.code]);
|
|
||||||
const lineHeight = theme.fontSize.code * 1.45;
|
|
||||||
const lineSelection = useMemo(() => {
|
|
||||||
if (!highlightedLines) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return clampLineSelection({
|
|
||||||
lineStart: location.lineStart,
|
|
||||||
lineEnd: location.lineEnd,
|
|
||||||
lineCount: highlightedLines.length,
|
|
||||||
});
|
|
||||||
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
|
||||||
|
|
||||||
const imageSource = useMemo(
|
const imageSource = useMemo(
|
||||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||||
[imagePreviewUri],
|
[imagePreviewUri],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lineSelection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
previewScrollRef.current?.scrollTo({
|
|
||||||
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [lineHeight, lineSelection, navigationRevision]);
|
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState} testID="file-preview-loading">
|
<View style={styles.centerState} testID="file-preview-loading">
|
||||||
@@ -280,51 +192,13 @@ function FilePreviewBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
|
||||||
const keyedLines = lines.map((tokens, index) => ({
|
|
||||||
key: `line-${index}`,
|
|
||||||
tokens,
|
|
||||||
lineNumber: index + 1,
|
|
||||||
}));
|
|
||||||
const codeLines = (
|
|
||||||
<View dataSet={CODE_SURFACE_DATASET}>
|
|
||||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
|
||||||
<CodeLine
|
|
||||||
key={key}
|
|
||||||
tokens={tokens}
|
|
||||||
lineNumber={lineNumber}
|
|
||||||
gutterWidth={gutterWidth}
|
|
||||||
highlighted={
|
|
||||||
Boolean(lineSelection) &&
|
|
||||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
|
||||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<ReadonlySource
|
||||||
<RNScrollView
|
preview={preview}
|
||||||
ref={previewScrollRef}
|
filename={filePath}
|
||||||
style={styles.previewContent}
|
location={location}
|
||||||
showsVerticalScrollIndicator
|
navigationRevision={navigationRevision}
|
||||||
>
|
/>
|
||||||
{isMobile ? (
|
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
||||||
) : (
|
|
||||||
<RNScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
|
||||||
>
|
|
||||||
{codeLines}
|
|
||||||
</RNScrollView>
|
|
||||||
)}
|
|
||||||
</RNScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +431,13 @@ function FilePanePresentation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
|
if (errorMessage === "File is too large to display") {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<TooLargeSource />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
describe("selectSourcePresentation", () => {
|
||||||
|
for (const platform of ["web", "native"] as const) {
|
||||||
|
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
|
||||||
|
it(`keeps ${platform} threshold edges predictable`, () => {
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const SOURCE_PRESENTATION_BUDGETS = {
|
||||||
|
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
|
||||||
|
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
|
||||||
|
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
|
||||||
|
|
||||||
|
export function selectSourcePresentation(input: {
|
||||||
|
size: number;
|
||||||
|
platform: SourcePlatform;
|
||||||
|
}): SourcePresentation {
|
||||||
|
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
|
||||||
|
if (input.size <= budgets.highlighted) return "highlighted";
|
||||||
|
if (input.size <= budgets.plain) return "plain";
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||||
|
import { selectSourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
interface FileSourceViewProps {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
size: number;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceLine {
|
||||||
|
number: number;
|
||||||
|
tokens: HighlightToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileSourceView({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
size,
|
||||||
|
tooLargeMessage,
|
||||||
|
}: FileSourceViewProps) {
|
||||||
|
const presentation = selectSourcePresentation({ size, platform: "native" });
|
||||||
|
if (presentation === "unsupported") {
|
||||||
|
return (
|
||||||
|
<View style={styles.unsupported} testID="file-source-too-large">
|
||||||
|
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<VirtualizedSource
|
||||||
|
content={content}
|
||||||
|
filename={filename}
|
||||||
|
location={location}
|
||||||
|
navigationRevision={navigationRevision}
|
||||||
|
presentation={presentation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VirtualizedSource({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
presentation,
|
||||||
|
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||||
|
presentation: "highlighted" | "plain";
|
||||||
|
}) {
|
||||||
|
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
if (presentation === "highlighted")
|
||||||
|
return highlightCode(content, filename).map((tokens, index) => ({
|
||||||
|
number: index + 1,
|
||||||
|
tokens,
|
||||||
|
}));
|
||||||
|
return content
|
||||||
|
.split("\n")
|
||||||
|
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||||
|
}, [content, filename, presentation]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.lineStart) return;
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: Math.min(location.lineStart - 1, lines.length - 1),
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, [lines.length, location.lineStart, navigationRevision]);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={lines}
|
||||||
|
keyExtractor={sourceLineKey}
|
||||||
|
initialNumToRender={24}
|
||||||
|
windowSize={9}
|
||||||
|
getItemLayout={sourceLineLayout}
|
||||||
|
renderItem={renderSourceLine}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceLineView({ line }: { line: SourceLine }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.line}>
|
||||||
|
<Text style={styles.gutter}>{line.number}</Text>
|
||||||
|
<Text selectable style={styles.text}>
|
||||||
|
{line.tokens.map((token) => (
|
||||||
|
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||||
|
{token.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLineKey(line: SourceLine): string {
|
||||||
|
return String(line.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
|
||||||
|
return { length: 20, offset: index * 20, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
unsupported: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||||
|
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
|
||||||
|
gutter: {
|
||||||
|
width: 56,
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
textAlign: "right",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.code,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.code,
|
||||||
|
lineHeight: theme.fontSize.code * 1.45,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FileSourceView } from "./view.native";
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Compartment, EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||||
|
import { editorTheme } from "../editor/extensions.web";
|
||||||
|
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
interface FileSourceViewProps {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
size: number;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
|
||||||
|
export function FileSourceView({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
size,
|
||||||
|
theme,
|
||||||
|
tooLargeMessage,
|
||||||
|
}: FileSourceViewProps) {
|
||||||
|
const presentation = selectSourcePresentation({ size, platform: "web" });
|
||||||
|
if (presentation === "unsupported") {
|
||||||
|
return (
|
||||||
|
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
|
||||||
|
{tooLargeMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReadonlyCodeMirror
|
||||||
|
content={content}
|
||||||
|
filename={filename}
|
||||||
|
location={location}
|
||||||
|
navigationRevision={navigationRevision}
|
||||||
|
presentation={presentation}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadonlyCodeMirror({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
presentation,
|
||||||
|
theme,
|
||||||
|
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||||
|
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||||
|
}) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const initial = useRef({ content, filename, presentation, theme });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const values = initial.current;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent: hostRef.current,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: values.content,
|
||||||
|
extensions: [
|
||||||
|
EditorState.readOnly.of(true),
|
||||||
|
EditorView.editable.of(false),
|
||||||
|
languageCompartment.of(
|
||||||
|
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||||
|
),
|
||||||
|
themeCompartment.of(editorTheme(values.theme)),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || view.state.doc.toString() === content) return;
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({
|
||||||
|
effects: [
|
||||||
|
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||||
|
themeCompartment.reconfigure(editorTheme(theme)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [filename, presentation, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !location.lineStart) return;
|
||||||
|
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||||
|
const from = view.state.doc.line(line).from;
|
||||||
|
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||||
|
}, [location.lineStart, navigationRevision]);
|
||||||
|
|
||||||
|
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageFor(input: {
|
||||||
|
filename: string;
|
||||||
|
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||||
|
}) {
|
||||||
|
return input.presentation === "highlighted"
|
||||||
|
? (getLanguageForFile(input.filename)?.extension ?? [])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||||
|
const UNSUPPORTED_STYLE = {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
} as const;
|
||||||
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
schedulePaint();
|
schedulePaint();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
|
if (
|
||||||
|
event.pointerType === "touch" &&
|
||||||
|
drag &&
|
||||||
|
!moved &&
|
||||||
|
hit?.kind === "cell" &&
|
||||||
|
hit.target &&
|
||||||
|
reviewActions
|
||||||
|
) {
|
||||||
selectionRef.current = null;
|
selectionRef.current = null;
|
||||||
reviewActions.onStartComment(hit.target);
|
reviewActions.onStartComment(hit.target);
|
||||||
schedulePaint();
|
schedulePaint();
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
|
|||||||
workspaces: "مساحات العمل",
|
workspaces: "مساحات العمل",
|
||||||
agents: "الوكلاء",
|
agents: "الوكلاء",
|
||||||
newAgent: "وكيل جديد",
|
newAgent: "وكيل جديد",
|
||||||
|
open: "فتح {{name}}",
|
||||||
|
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
|
||||||
|
openInFocusedPane: "فتح {{name}} في الجزء النشط",
|
||||||
addProject: "إضافة مشروع",
|
addProject: "إضافة مشروع",
|
||||||
home: "بيت",
|
home: "بيت",
|
||||||
groupByProject: "تجميع حسب المشروع",
|
groupByProject: "تجميع حسب المشروع",
|
||||||
@@ -1700,6 +1703,7 @@ export const ar: TranslationResources = {
|
|||||||
loading: "جارٍ تحميل الملف...",
|
loading: "جارٍ تحميل الملف...",
|
||||||
noPreview: "لا تتوفر معاينة",
|
noPreview: "لا تتوفر معاينة",
|
||||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||||
|
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||||
failedToLoad: "فشل تحميل الملف",
|
failedToLoad: "فشل تحميل الملف",
|
||||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1728,6 +1732,7 @@ export const ar: TranslationResources = {
|
|||||||
label: "الملفات",
|
label: "الملفات",
|
||||||
subtitle: "ملفات مساحة العمل",
|
subtitle: "ملفات مساحة العمل",
|
||||||
tooltip: "تصفح ملفات مساحة العمل",
|
tooltip: "تصفح ملفات مساحة العمل",
|
||||||
|
chooseFile: "اختر ملفًا",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "طلب السحب",
|
label: "طلب السحب",
|
||||||
@@ -2342,9 +2347,9 @@ export const ar: TranslationResources = {
|
|||||||
featuresLabel: "السمات",
|
featuresLabel: "السمات",
|
||||||
featureCount: "{{count}} سمة",
|
featureCount: "{{count}} سمة",
|
||||||
featureCountOne: "{{count}} سمة",
|
featureCountOne: "{{count}} سمة",
|
||||||
notesLabel: "ملاحظات للوكلاء",
|
notesLabel: "متى يُستخدم",
|
||||||
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
|
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
|
||||||
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.',
|
notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
|
||||||
save: "حفظ",
|
save: "حفظ",
|
||||||
saving: "جارٍ الحفظ...",
|
saving: "جارٍ الحفظ...",
|
||||||
remove: "إزالة",
|
remove: "إزالة",
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const en = {
|
|||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "New agent",
|
newAgent: "New agent",
|
||||||
|
open: "Open {{name}}",
|
||||||
|
openInSidePanel: "Open {{name}} in Side panel",
|
||||||
|
openInFocusedPane: "Open {{name}} in focused pane",
|
||||||
addProject: "Add project",
|
addProject: "Add project",
|
||||||
home: "Home",
|
home: "Home",
|
||||||
groupByProject: "Group by project",
|
groupByProject: "Group by project",
|
||||||
@@ -1710,6 +1713,7 @@ export const en = {
|
|||||||
loading: "Loading file...",
|
loading: "Loading file...",
|
||||||
noPreview: "No preview available",
|
noPreview: "No preview available",
|
||||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||||
|
tooLargeToDisplay: "This file is too large to display",
|
||||||
failedToLoad: "Failed to load file",
|
failedToLoad: "Failed to load file",
|
||||||
failedToLoadPreview: "Failed to load file preview",
|
failedToLoadPreview: "Failed to load file preview",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1738,6 +1742,7 @@ export const en = {
|
|||||||
label: "Files",
|
label: "Files",
|
||||||
subtitle: "Workspace files",
|
subtitle: "Workspace files",
|
||||||
tooltip: "Browse workspace files",
|
tooltip: "Browse workspace files",
|
||||||
|
chooseFile: "Choose a file",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Pull request",
|
label: "Pull request",
|
||||||
@@ -2409,10 +2414,9 @@ export const en = {
|
|||||||
featuresLabel: "Features",
|
featuresLabel: "Features",
|
||||||
featureCount: "{{count}} features",
|
featureCount: "{{count}} features",
|
||||||
featureCountOne: "{{count}} feature",
|
featureCountOne: "{{count}} feature",
|
||||||
notesLabel: "Notes for agents",
|
notesLabel: "When to use",
|
||||||
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
|
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
|
||||||
notesHint:
|
notesHint: "Helps agents choose this profile when starting another agent.",
|
||||||
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
|
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saving: "Saving...",
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
|
|||||||
workspaces: "Espacios de trabajo",
|
workspaces: "Espacios de trabajo",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Nuevo agente",
|
newAgent: "Nuevo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} en el panel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
|
||||||
addProject: "Agregar proyecto",
|
addProject: "Agregar proyecto",
|
||||||
home: "Hogar",
|
home: "Hogar",
|
||||||
groupByProject: "Agrupar por proyecto",
|
groupByProject: "Agrupar por proyecto",
|
||||||
@@ -1746,6 +1749,7 @@ export const es: TranslationResources = {
|
|||||||
loading: "Cargando archivo...",
|
loading: "Cargando archivo...",
|
||||||
noPreview: "No hay vista previa disponible",
|
noPreview: "No hay vista previa disponible",
|
||||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||||
|
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
|
||||||
failedToLoad: "No se pudo cargar el archivo",
|
failedToLoad: "No se pudo cargar el archivo",
|
||||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1774,6 +1778,7 @@ export const es: TranslationResources = {
|
|||||||
label: "Archivos",
|
label: "Archivos",
|
||||||
subtitle: "Archivos del espacio de trabajo",
|
subtitle: "Archivos del espacio de trabajo",
|
||||||
tooltip: "Explorar archivos del espacio de trabajo",
|
tooltip: "Explorar archivos del espacio de trabajo",
|
||||||
|
chooseFile: "Elige un archivo",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Solicitud de extracción",
|
label: "Solicitud de extracción",
|
||||||
@@ -2396,10 +2401,9 @@ export const es: TranslationResources = {
|
|||||||
featuresLabel: "Características",
|
featuresLabel: "Características",
|
||||||
featureCount: "{{count}} características",
|
featureCount: "{{count}} características",
|
||||||
featureCountOne: "{{count}} característica",
|
featureCountOne: "{{count}} característica",
|
||||||
notesLabel: "Notas para los agentes",
|
notesLabel: "Cuándo usarlo",
|
||||||
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
|
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
|
||||||
notesHint:
|
notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
|
||||||
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
|
|
||||||
save: "Guardar",
|
save: "Guardar",
|
||||||
saving: "Guardando...",
|
saving: "Guardando...",
|
||||||
remove: "Eliminar",
|
remove: "Eliminar",
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
|
|||||||
workspaces: "Espaces de travail",
|
workspaces: "Espaces de travail",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "Nouvel agent",
|
newAgent: "Nouvel agent",
|
||||||
|
open: "Ouvrir {{name}}",
|
||||||
|
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
|
||||||
|
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
|
||||||
addProject: "Ajouter un projet",
|
addProject: "Ajouter un projet",
|
||||||
home: "Maison",
|
home: "Maison",
|
||||||
groupByProject: "Grouper par projet",
|
groupByProject: "Grouper par projet",
|
||||||
@@ -1750,6 +1753,7 @@ export const fr: TranslationResources = {
|
|||||||
loading: "Chargement du fichier...",
|
loading: "Chargement du fichier...",
|
||||||
noPreview: "Aucun aperçu disponible",
|
noPreview: "Aucun aperçu disponible",
|
||||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||||
|
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
|
||||||
failedToLoad: "Échec du chargement du fichier",
|
failedToLoad: "Échec du chargement du fichier",
|
||||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1778,6 +1782,7 @@ export const fr: TranslationResources = {
|
|||||||
label: "Fichiers",
|
label: "Fichiers",
|
||||||
subtitle: "Fichiers de l’espace de travail",
|
subtitle: "Fichiers de l’espace de travail",
|
||||||
tooltip: "Parcourir les fichiers de l’espace de travail",
|
tooltip: "Parcourir les fichiers de l’espace de travail",
|
||||||
|
chooseFile: "Choisissez un fichier",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Demande de fusion",
|
label: "Demande de fusion",
|
||||||
@@ -2403,11 +2408,10 @@ export const fr: TranslationResources = {
|
|||||||
featuresLabel: "Caractéristiques",
|
featuresLabel: "Caractéristiques",
|
||||||
featureCount: "{{count}} caractéristiques",
|
featureCount: "{{count}} caractéristiques",
|
||||||
featureCountOne: "{{count}} caractéristique",
|
featureCountOne: "{{count}} caractéristique",
|
||||||
notesLabel: "Notes pour les agents",
|
notesLabel: "Quand l’utiliser",
|
||||||
notesPlaceholder:
|
notesPlaceholder:
|
||||||
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
|
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
|
||||||
notesHint:
|
notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
|
||||||
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
|
|
||||||
save: "Enregistrer",
|
save: "Enregistrer",
|
||||||
saving: "Enregistrement...",
|
saving: "Enregistrement...",
|
||||||
remove: "Supprimer",
|
remove: "Supprimer",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
|
|||||||
workspaces: "ワークスペース",
|
workspaces: "ワークスペース",
|
||||||
agents: "エージェント",
|
agents: "エージェント",
|
||||||
newAgent: "新しいエージェント",
|
newAgent: "新しいエージェント",
|
||||||
|
open: "{{name}}を開く",
|
||||||
|
openInSidePanel: "{{name}}をサイドパネルで開く",
|
||||||
|
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
|
||||||
addProject: "プロジェクトを追加",
|
addProject: "プロジェクトを追加",
|
||||||
home: "ホーム",
|
home: "ホーム",
|
||||||
groupByProject: "プロジェクトでグループ化",
|
groupByProject: "プロジェクトでグループ化",
|
||||||
@@ -1717,6 +1720,7 @@ export const ja: TranslationResources = {
|
|||||||
loading: "ファイルを読み込み中...",
|
loading: "ファイルを読み込み中...",
|
||||||
noPreview: "プレビューが利用できません",
|
noPreview: "プレビューが利用できません",
|
||||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||||
|
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1745,6 +1749,7 @@ export const ja: TranslationResources = {
|
|||||||
label: "ファイル",
|
label: "ファイル",
|
||||||
subtitle: "ワークスペースのファイル",
|
subtitle: "ワークスペースのファイル",
|
||||||
tooltip: "ワークスペースのファイルを参照",
|
tooltip: "ワークスペースのファイルを参照",
|
||||||
|
chooseFile: "ファイルを選択",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "プルリクエスト",
|
label: "プルリクエスト",
|
||||||
@@ -2365,10 +2370,9 @@ export const ja: TranslationResources = {
|
|||||||
featuresLabel: "機能",
|
featuresLabel: "機能",
|
||||||
featureCount: "{{count}}個の機能",
|
featureCount: "{{count}}個の機能",
|
||||||
featureCountOne: "{{count}}個の機能",
|
featureCountOne: "{{count}}個の機能",
|
||||||
notesLabel: "エージェントへのメモ",
|
notesLabel: "使用する場面",
|
||||||
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
|
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
|
||||||
notesHint:
|
notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
|
||||||
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
|
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中...",
|
saving: "保存中...",
|
||||||
remove: "削除",
|
remove: "削除",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
|
|||||||
workspaces: "워크스페이스",
|
workspaces: "워크스페이스",
|
||||||
agents: "에이전트",
|
agents: "에이전트",
|
||||||
newAgent: "새 에이전트",
|
newAgent: "새 에이전트",
|
||||||
|
open: "{{name}} 열기",
|
||||||
|
openInSidePanel: "사이드 패널에서 {{name}} 열기",
|
||||||
|
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
|
||||||
addProject: "프로젝트 추가",
|
addProject: "프로젝트 추가",
|
||||||
home: "홈",
|
home: "홈",
|
||||||
groupByProject: "프로젝트별 그룹화",
|
groupByProject: "프로젝트별 그룹화",
|
||||||
@@ -1710,6 +1713,7 @@ export const ko: TranslationResources = {
|
|||||||
loading: "파일 불러오는 중...",
|
loading: "파일 불러오는 중...",
|
||||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||||
|
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||||
failedToLoad: "파일을 불러오지 못했습니다",
|
failedToLoad: "파일을 불러오지 못했습니다",
|
||||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1738,6 +1742,7 @@ export const ko: TranslationResources = {
|
|||||||
label: "파일",
|
label: "파일",
|
||||||
subtitle: "워크스페이스 파일",
|
subtitle: "워크스페이스 파일",
|
||||||
tooltip: "워크스페이스 파일 탐색",
|
tooltip: "워크스페이스 파일 탐색",
|
||||||
|
chooseFile: "파일 선택",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "풀 리퀘스트",
|
label: "풀 리퀘스트",
|
||||||
@@ -2354,10 +2359,9 @@ export const ko: TranslationResources = {
|
|||||||
featuresLabel: "기능",
|
featuresLabel: "기능",
|
||||||
featureCount: "기능 {{count}}개",
|
featureCount: "기능 {{count}}개",
|
||||||
featureCountOne: "기능 {{count}}개",
|
featureCountOne: "기능 {{count}}개",
|
||||||
notesLabel: "에이전트를 위한 메모",
|
notesLabel: "사용 시점",
|
||||||
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
|
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
|
||||||
notesHint:
|
notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
|
||||||
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
|
|
||||||
save: "저장",
|
save: "저장",
|
||||||
saving: "저장하는 중...",
|
saving: "저장하는 중...",
|
||||||
remove: "제거",
|
remove: "제거",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
|
|||||||
workspaces: "Espaços de trabalho",
|
workspaces: "Espaços de trabalho",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Novo agente",
|
newAgent: "Novo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} no painel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} no painel em foco",
|
||||||
addProject: "Adicionar projeto",
|
addProject: "Adicionar projeto",
|
||||||
home: "Início",
|
home: "Início",
|
||||||
groupByProject: "Agrupar por projeto",
|
groupByProject: "Agrupar por projeto",
|
||||||
@@ -1732,6 +1735,7 @@ export const ptBR: TranslationResources = {
|
|||||||
loading: "Carregando arquivo...",
|
loading: "Carregando arquivo...",
|
||||||
noPreview: "Nenhuma prévia disponível",
|
noPreview: "Nenhuma prévia disponível",
|
||||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||||
|
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
|
||||||
failedToLoad: "Falha ao carregar arquivo",
|
failedToLoad: "Falha ao carregar arquivo",
|
||||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1760,6 +1764,7 @@ export const ptBR: TranslationResources = {
|
|||||||
label: "Arquivos",
|
label: "Arquivos",
|
||||||
subtitle: "Arquivos do espaço de trabalho",
|
subtitle: "Arquivos do espaço de trabalho",
|
||||||
tooltip: "Explorar arquivos do espaço de trabalho",
|
tooltip: "Explorar arquivos do espaço de trabalho",
|
||||||
|
chooseFile: "Escolha um arquivo",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "Pull request",
|
label: "Pull request",
|
||||||
@@ -2381,10 +2386,9 @@ export const ptBR: TranslationResources = {
|
|||||||
featuresLabel: "Recursos",
|
featuresLabel: "Recursos",
|
||||||
featureCount: "{{count}} recursos",
|
featureCount: "{{count}} recursos",
|
||||||
featureCountOne: "{{count}} recurso",
|
featureCountOne: "{{count}} recurso",
|
||||||
notesLabel: "Notas para os agentes",
|
notesLabel: "Quando usar",
|
||||||
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
|
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
|
||||||
notesHint:
|
notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.",
|
||||||
'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.',
|
|
||||||
save: "Salvar",
|
save: "Salvar",
|
||||||
saving: "Salvando...",
|
saving: "Salvando...",
|
||||||
remove: "Remover",
|
remove: "Remover",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
|
|||||||
workspaces: "Рабочие пространства",
|
workspaces: "Рабочие пространства",
|
||||||
agents: "Агенты",
|
agents: "Агенты",
|
||||||
newAgent: "Новый агент",
|
newAgent: "Новый агент",
|
||||||
|
open: "Открыть {{name}}",
|
||||||
|
openInSidePanel: "Открыть {{name}} в боковой панели",
|
||||||
|
openInFocusedPane: "Открыть {{name}} в активной области",
|
||||||
addProject: "Добавить проект",
|
addProject: "Добавить проект",
|
||||||
home: "Главная",
|
home: "Главная",
|
||||||
groupByProject: "Группировать по проекту",
|
groupByProject: "Группировать по проекту",
|
||||||
@@ -1730,6 +1733,7 @@ export const ru: TranslationResources = {
|
|||||||
loading: "Загрузка файла...",
|
loading: "Загрузка файла...",
|
||||||
noPreview: "Предварительный просмотр недоступен",
|
noPreview: "Предварительный просмотр недоступен",
|
||||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||||
|
tooLargeToDisplay: "Этот файл слишком велик для отображения",
|
||||||
failedToLoad: "Не удалось загрузить файл",
|
failedToLoad: "Не удалось загрузить файл",
|
||||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1759,6 +1763,7 @@ export const ru: TranslationResources = {
|
|||||||
label: "Файлы",
|
label: "Файлы",
|
||||||
subtitle: "Файлы рабочего пространства",
|
subtitle: "Файлы рабочего пространства",
|
||||||
tooltip: "Просмотр файлов рабочего пространства",
|
tooltip: "Просмотр файлов рабочего пространства",
|
||||||
|
chooseFile: "Выберите файл",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "PR",
|
label: "PR",
|
||||||
@@ -2386,10 +2391,9 @@ export const ru: TranslationResources = {
|
|||||||
featuresLabel: "Функции",
|
featuresLabel: "Функции",
|
||||||
featureCount: "Функции: {{count}}",
|
featureCount: "Функции: {{count}}",
|
||||||
featureCountOne: "{{count}} функция",
|
featureCountOne: "{{count}} функция",
|
||||||
notesLabel: "Заметки для агентов",
|
notesLabel: "Когда использовать",
|
||||||
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
|
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
|
||||||
notesHint:
|
notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.",
|
||||||
"MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.",
|
|
||||||
save: "Сохранить",
|
save: "Сохранить",
|
||||||
saving: "Сохранение...",
|
saving: "Сохранение...",
|
||||||
remove: "Удалить",
|
remove: "Удалить",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
|
|||||||
workspaces: "工作区",
|
workspaces: "工作区",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "新建 Agent",
|
newAgent: "新建 Agent",
|
||||||
|
open: "打开{{name}}",
|
||||||
|
openInSidePanel: "在侧边面板中打开{{name}}",
|
||||||
|
openInFocusedPane: "在当前窗格中打开{{name}}",
|
||||||
addProject: "添加 project",
|
addProject: "添加 project",
|
||||||
home: "首页",
|
home: "首页",
|
||||||
groupByProject: "按项目分组",
|
groupByProject: "按项目分组",
|
||||||
@@ -1680,6 +1683,7 @@ export const zhCN: TranslationResources = {
|
|||||||
loading: "正在加载文件...",
|
loading: "正在加载文件...",
|
||||||
noPreview: "没有可用预览",
|
noPreview: "没有可用预览",
|
||||||
binaryPreviewUnavailable: "二进制预览不可用",
|
binaryPreviewUnavailable: "二进制预览不可用",
|
||||||
|
tooLargeToDisplay: "此文件过大,无法显示",
|
||||||
failedToLoad: "加载文件失败",
|
failedToLoad: "加载文件失败",
|
||||||
failedToLoadPreview: "加载文件预览失败",
|
failedToLoadPreview: "加载文件预览失败",
|
||||||
editor: {
|
editor: {
|
||||||
@@ -1708,6 +1712,7 @@ export const zhCN: TranslationResources = {
|
|||||||
label: "文件",
|
label: "文件",
|
||||||
subtitle: "工作区文件",
|
subtitle: "工作区文件",
|
||||||
tooltip: "浏览工作区文件",
|
tooltip: "浏览工作区文件",
|
||||||
|
chooseFile: "选择文件",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
label: "拉取请求",
|
label: "拉取请求",
|
||||||
@@ -2316,9 +2321,9 @@ export const zhCN: TranslationResources = {
|
|||||||
featuresLabel: "Features",
|
featuresLabel: "Features",
|
||||||
featureCount: "{{count}} 个 Feature",
|
featureCount: "{{count}} 个 Feature",
|
||||||
featureCountOne: "{{count}} 个 Feature",
|
featureCountOne: "{{count}} 个 Feature",
|
||||||
notesLabel: "给 Agent 的说明",
|
notesLabel: "适用场景",
|
||||||
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
|
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
|
||||||
notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。",
|
notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中...",
|
saving: "保存中...",
|
||||||
remove: "移除",
|
remove: "移除",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
||||||
|
|
||||||
|
export type WorkspacePanelTarget = "changes" | "files" | "pull-request";
|
||||||
|
export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane";
|
||||||
|
|
||||||
export type KeyboardActionId =
|
export type KeyboardActionId =
|
||||||
| "agent.interrupt"
|
| "agent.interrupt"
|
||||||
| "message-input.focus"
|
| "message-input.focus"
|
||||||
@@ -19,6 +22,15 @@ export type KeyboardActionId =
|
|||||||
| "workspace.tab.close-current"
|
| "workspace.tab.close-current"
|
||||||
| "workspace.tab.navigate-index"
|
| "workspace.tab.navigate-index"
|
||||||
| "workspace.tab.navigate-relative"
|
| "workspace.tab.navigate-relative"
|
||||||
|
| "workspace.tab.open"
|
||||||
|
| "workspace.tab.rename-current"
|
||||||
|
| "workspace.tab.reload-current"
|
||||||
|
| "workspace.tab.copy-resume-command"
|
||||||
|
| "workspace.tab.copy-id"
|
||||||
|
| "workspace.tab.copy-file-path"
|
||||||
|
| "workspace.tab.close-left"
|
||||||
|
| "workspace.tab.close-right"
|
||||||
|
| "workspace.tab.close-others"
|
||||||
| "workspace.pane.split.right"
|
| "workspace.pane.split.right"
|
||||||
| "workspace.pane.split.down"
|
| "workspace.pane.split.down"
|
||||||
| "workspace.pane.focus.left"
|
| "workspace.pane.focus.left"
|
||||||
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||||
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
||||||
|
| {
|
||||||
|
id: "workspace.tab.open";
|
||||||
|
scope: KeyboardActionScope;
|
||||||
|
target: WorkspacePanelTarget;
|
||||||
|
placement: WorkspacePanelPlacement;
|
||||||
|
}
|
||||||
|
| { id: "workspace.tab.rename-current"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.reload-current"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-id"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-left"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-right"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-others"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
||||||
|
|||||||
@@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
const targetCases = [
|
const targetCases = [
|
||||||
["a", "KeyA", "workspace.tab.target.agent"],
|
["a", "KeyA", "workspace.tab.target.agent"],
|
||||||
["b", "KeyB", "workspace.tab.target.browser"],
|
["b", "KeyB", "workspace.tab.target.browser"],
|
||||||
["c", "KeyC", "workspace.tab.target.changes"],
|
["g", "KeyG", "workspace.tab.target.changes"],
|
||||||
["e", "KeyE", "workspace.tab.target.files"],
|
["e", "KeyE", "workspace.tab.target.files"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
|
|
||||||
it("uses the existing override map for target matching and display", () => {
|
it("uses the existing override map for target matching and display", () => {
|
||||||
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac";
|
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac";
|
||||||
const overrides = { [bindingId]: "Ctrl+Shift+G" };
|
const overrides = { [bindingId]: "Ctrl+Shift+H" };
|
||||||
const rebound = resolveShortcut({
|
const rebound = resolveShortcut({
|
||||||
event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true },
|
event: { key: "h", code: "KeyH", ctrlKey: true, shiftKey: true },
|
||||||
context: { ...desktopNonMac, focusScope: "other" },
|
context: { ...desktopNonMac, focusScope: "other" },
|
||||||
bindings: buildEffectiveBindings(overrides),
|
bindings: buildEffectiveBindings(overrides),
|
||||||
});
|
});
|
||||||
@@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => {
|
|||||||
expect(original.match).toBeNull();
|
expect(original.match).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
|
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
|
||||||
).toEqual([["ctrl", "shift", "G"]]);
|
).toEqual([["ctrl", "shift", "H"]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -454,9 +454,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive.
|
||||||
id: "workspace-tab-target-changes-cmd-shift-c-mac",
|
id: "workspace-tab-target-changes-cmd-shift-c-mac",
|
||||||
action: "workspace.tab.target.changes",
|
action: "workspace.tab.target.changes",
|
||||||
combo: "Cmd+Shift+C",
|
combo: "Cmd+Shift+G",
|
||||||
when: { mac: true, commandCenter: false },
|
when: { mac: true, commandCenter: false },
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-target-changes",
|
id: "workspace-tab-target-changes",
|
||||||
@@ -465,9 +466,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive.
|
||||||
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
|
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
|
||||||
action: "workspace.tab.target.changes",
|
action: "workspace.tab.target.changes",
|
||||||
combo: "Ctrl+Shift+C",
|
combo: "Ctrl+Shift+G",
|
||||||
when: { mac: false, commandCenter: false, terminal: false },
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-target-changes",
|
id: "workspace-tab-target-changes",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
|||||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
|
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||||
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
|
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
|
||||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
@@ -748,6 +749,7 @@ function AgentPanelBody({
|
|||||||
return (
|
return (
|
||||||
<ChatAgentContent
|
<ChatAgentContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -760,6 +762,7 @@ function AgentPanelBody({
|
|||||||
|
|
||||||
function ChatAgentContent({
|
function ChatAgentContent({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
client,
|
client,
|
||||||
@@ -768,6 +771,7 @@ function ChatAgentContent({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
client: ReturnType<typeof useHostRuntimeClient>;
|
client: ReturnType<typeof useHostRuntimeClient>;
|
||||||
@@ -1164,6 +1168,7 @@ function ChatAgentContent({
|
|||||||
return (
|
return (
|
||||||
<ChatAgentReadyContent
|
<ChatAgentReadyContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
@@ -1192,6 +1197,7 @@ function ChatAgentContent({
|
|||||||
|
|
||||||
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
isArchivingCurrentAgent,
|
isArchivingCurrentAgent,
|
||||||
@@ -1216,6 +1222,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
@@ -1240,8 +1247,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCompactFormFactor = useIsCompactFormFactor();
|
|
||||||
const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
|
|
||||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||||
@@ -1252,9 +1257,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
rows: subagentRows,
|
rows: subagentRows,
|
||||||
});
|
});
|
||||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||||
const showAgentTracks =
|
const hasVisibleAgentTracks = hasAgentTracks({
|
||||||
hasActiveComposer &&
|
|
||||||
hasAgentTracks({
|
|
||||||
subagentRows,
|
subagentRows,
|
||||||
tasks,
|
tasks,
|
||||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||||
@@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
<AgentStreamSection
|
<AgentStreamSection
|
||||||
streamViewRef={streamViewRef}
|
streamViewRef={streamViewRef}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
agent={effectiveAgent}
|
agent={effectiveAgent}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
|
hasActiveComposer={hasActiveComposer}
|
||||||
|
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
||||||
toast={toastApi}
|
toast={toastApi}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
{showAgentTracks ? (
|
{hasActiveComposer ? (
|
||||||
<AgentTracks
|
<AgentTracks
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
subagentRows={subagentRows}
|
subagentRows={subagentRows}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||||
@@ -1409,24 +1415,35 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
const AgentStreamSection = memo(function AgentStreamSection({
|
const AgentStreamSection = memo(function AgentStreamSection({
|
||||||
streamViewRef,
|
streamViewRef,
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
agent,
|
agent,
|
||||||
routeBottomAnchorRequest,
|
routeBottomAnchorRequest,
|
||||||
hasAppliedAuthoritativeHistory,
|
hasAppliedAuthoritativeHistory,
|
||||||
bottomOverlayTailClearance,
|
hasActiveComposer,
|
||||||
|
hasVisibleAgentTracks,
|
||||||
toast,
|
toast,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agent: AgentScreenAgent;
|
agent: AgentScreenAgent;
|
||||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||||
hasAppliedAuthoritativeHistory: boolean;
|
hasAppliedAuthoritativeHistory: boolean;
|
||||||
bottomOverlayTailClearance: number;
|
hasActiveComposer: boolean;
|
||||||
|
hasVisibleAgentTracks: boolean;
|
||||||
toast: ReturnType<typeof useToastHost>["api"];
|
toast: ReturnType<typeof useToastHost>["api"];
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isCompactFormFactor = useIsCompactFormFactor();
|
||||||
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||||
|
const hasVisibleComposerTracks =
|
||||||
|
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
|
||||||
|
const bottomOverlayTailClearance = hasVisibleComposerTracks
|
||||||
|
? resolveComposerTrackTailClearance(isCompactFormFactor)
|
||||||
|
: 0;
|
||||||
const streamItemsRaw = useSessionStore((state) =>
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { memo, useCallback, type ReactElement } from "react";
|
import { memo, useCallback, type ReactElement } from "react";
|
||||||
|
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||||
|
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||||
import { AgentTaskList } from "@/composer/task-list";
|
import { AgentTaskList } from "@/composer/task-list";
|
||||||
import { ComposerTrackBar } from "@/composer/tracks";
|
import { ComposerTrackBar } from "@/composer/tracks";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -15,29 +17,32 @@ import { SubagentsTrack } from "@/subagents/track";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openSupportingTab } from "@/workspace-tabs/side-panel";
|
import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
|
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
||||||
|
* the composer.
|
||||||
*
|
*
|
||||||
* The row shares the composer's keyboard transform and owns the space between itself and the
|
* The row shares the composer's keyboard transform and owns the space between itself and the
|
||||||
* transcript. Its data remains agent state: a subagent row opens a tab and the task list reads
|
* transcript. Each pill owns its action while tab placement stays behind the workspace boundary.
|
||||||
* the agent's stream.
|
|
||||||
*/
|
*/
|
||||||
export const AgentTracks = memo(function AgentTracks({
|
export const AgentTracks = memo(function AgentTracks({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
subagentRows,
|
subagentRows,
|
||||||
tasks,
|
tasks,
|
||||||
archiveFinishedStatus,
|
archiveFinishedStatus,
|
||||||
onArchiveFinished,
|
onArchiveFinished,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
subagentRows: SubagentRow[];
|
subagentRows: SubagentRow[];
|
||||||
tasks: TodoEntry[] | undefined;
|
tasks: TodoEntry[] | undefined;
|
||||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||||
onArchiveFinished: () => void;
|
onArchiveFinished: () => void;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { workspaceId, tabId, openTab } = usePaneContext();
|
const { tabId, openTab } = usePaneContext();
|
||||||
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||||
const openInSidePanelByDefault = useSettings(
|
const openInSidePanelByDefault = useSettings(
|
||||||
@@ -87,13 +92,25 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
},
|
},
|
||||||
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
|
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
|
||||||
);
|
);
|
||||||
|
const handleOpenChanges = useCallback(() => {
|
||||||
|
if (!workspaceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleSupportingTab({
|
||||||
|
isCompact,
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "working_diff" },
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
});
|
||||||
|
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
|
||||||
|
|
||||||
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerTrackBar>
|
<ComposerTrackBar>
|
||||||
|
<AgentTaskList tasks={tasks} />
|
||||||
<SubagentsTrack
|
<SubagentsTrack
|
||||||
rows={subagentRows}
|
rows={subagentRows}
|
||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
@@ -103,7 +120,11 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
archiveFinishedStatus={archiveFinishedStatus}
|
archiveFinishedStatus={archiveFinishedStatus}
|
||||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||||
/>
|
/>
|
||||||
<AgentTaskList tasks={tasks} />
|
<WorkspaceDiffStatPill
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
onPress={handleOpenChanges}
|
||||||
|
/>
|
||||||
</ComposerTrackBar>
|
</ComposerTrackBar>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { FolderTree } from "lucide-react-native";
|
import { FolderTree } from "lucide-react-native";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
@@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context";
|
|||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import { TreeRail } from "@/components/tree-rail";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
const CENTERED_PADDED_STYLE = {
|
const FILE_TREE_WIDTH = 220;
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 16,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function useFilesPanelDescriptor() {
|
function useFilesPanelDescriptor() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -35,6 +32,7 @@ function FilesPanel() {
|
|||||||
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
||||||
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
invariant(target.kind === "files", "FilesPanel requires files target");
|
invariant(target.kind === "files", "FilesPanel requires files target");
|
||||||
const onOpenFile = useCallback(
|
const onOpenFile = useCallback(
|
||||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||||
@@ -42,11 +40,12 @@ function FilesPanel() {
|
|||||||
);
|
);
|
||||||
if (!workspaceRoot) {
|
if (!workspaceRoot) {
|
||||||
return (
|
return (
|
||||||
<View style={CENTERED_PADDED_STYLE}>
|
<View style={styles.centerState}>
|
||||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
<FileExplorerPane
|
<FileExplorerPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -56,6 +55,21 @@ function FilesPanel() {
|
|||||||
onAddToChat={canAddToChat ? addFile : undefined}
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
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"> = {
|
export const filesPanelRegistration: PanelRegistration<"files"> = {
|
||||||
@@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = {
|
|||||||
component: FilesPanel,
|
component: FilesPanel,
|
||||||
useDescriptor: useFilesPanelDescriptor,
|
useDescriptor: useFilesPanelDescriptor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
centerState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
|
|
||||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
import {
|
import {
|
||||||
@@ -441,7 +440,6 @@ export function InlineReviewEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
||||||
const focus = useWorkspaceFocusRestoration();
|
|
||||||
const [body, setBody] = useState(initialBody);
|
const [body, setBody] = useState(initialBody);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const trimmedBody = body.trim();
|
const trimmedBody = body.trim();
|
||||||
@@ -452,13 +450,11 @@ export function InlineReviewEditor({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
focus.unfocus();
|
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
}, [focus]);
|
}, []);
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
focus.restore();
|
}, []);
|
||||||
}, [focus]);
|
|
||||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
|||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { openSupportingTab } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||||
@@ -1169,6 +1171,10 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
}: ResolvedWorkspaceDesktopTabsRowProps) {
|
}: ResolvedWorkspaceDesktopTabsRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const openInSidePanelByDefault = useSettings(
|
||||||
|
(settings) => settings.openSupportingTabsInSidePanel,
|
||||||
|
);
|
||||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||||
@@ -1450,6 +1456,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
[openNewTab, paneId, workspaceKey],
|
[openNewTab, paneId, workspaceKey],
|
||||||
);
|
);
|
||||||
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
|
||||||
|
const handleOpenChangesShortcut = useCallback(() => {
|
||||||
|
openSupportingTab({
|
||||||
|
isCompact,
|
||||||
|
workspaceKey,
|
||||||
|
target: CHANGES_TARGET,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
});
|
||||||
|
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
|
||||||
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
|
||||||
const handleOpenPullRequest = useCallback(
|
const handleOpenPullRequest = useCallback(
|
||||||
() => openPanelTarget(PULL_REQUEST_TARGET),
|
() => openPanelTarget(PULL_REQUEST_TARGET),
|
||||||
@@ -1484,7 +1498,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
case "workspace.tab.target.changes":
|
case "workspace.tab.target.changes":
|
||||||
if (!isGit) return true;
|
if (!isGit) return true;
|
||||||
setNewTabMenuOpen(false);
|
setNewTabMenuOpen(false);
|
||||||
handleOpenChanges();
|
handleOpenChangesShortcut();
|
||||||
return true;
|
return true;
|
||||||
case "workspace.tab.target.files":
|
case "workspace.tab.target.files":
|
||||||
setNewTabMenuOpen(false);
|
setNewTabMenuOpen(false);
|
||||||
@@ -1497,7 +1511,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
[
|
[
|
||||||
handleCreateAgentTab,
|
handleCreateAgentTab,
|
||||||
handleCreateBrowser,
|
handleCreateBrowser,
|
||||||
handleOpenChanges,
|
handleOpenChangesShortcut,
|
||||||
handleOpenFiles,
|
handleOpenFiles,
|
||||||
isFocused,
|
isFocused,
|
||||||
isGit,
|
isGit,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, type ReactElement, type ReactNode } from "react";
|
import { memo, useCallback, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
@@ -31,6 +32,7 @@ const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMappin
|
|||||||
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
||||||
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
||||||
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
||||||
|
const LAUNCHER_ROW_DATA_SET = { emptyPaneLauncherRow: "true" };
|
||||||
|
|
||||||
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
||||||
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
||||||
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
|
|||||||
return keys ? <Shortcut chord={keys} /> : null;
|
return keys ? <Shortcut chord={keys} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
|
const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
|
||||||
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
|
|
||||||
|
function getLauncherRowStyle({
|
||||||
|
pressed,
|
||||||
|
hovered = false,
|
||||||
|
focused = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
|
||||||
|
return [
|
||||||
|
styles.row,
|
||||||
|
(hovered || focused) && styles.rowHovered,
|
||||||
|
focused && styles.rowFocused,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +75,8 @@ function LauncherRow({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
|
dataSet={LAUNCHER_ROW_DATA_SET}
|
||||||
|
tabIndex={-1}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={getLauncherRowStyle}
|
style={getLauncherRowStyle}
|
||||||
>
|
>
|
||||||
@@ -75,6 +90,8 @@ function LauncherRow({
|
|||||||
|
|
||||||
export interface WorkspacePaneEmptyStateProps {
|
export interface WorkspacePaneEmptyStateProps {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
|
/** Logical pane focus. On desktop, the launcher turns this into DOM focus. */
|
||||||
|
isFocused: boolean;
|
||||||
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
||||||
showChanges: boolean;
|
showChanges: boolean;
|
||||||
showPullRequest: boolean;
|
showPullRequest: boolean;
|
||||||
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
|
|||||||
|
|
||||||
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
||||||
paneId,
|
paneId,
|
||||||
|
isFocused,
|
||||||
showChanges,
|
showChanges,
|
||||||
showPullRequest,
|
showPullRequest,
|
||||||
onOpenView,
|
onOpenView,
|
||||||
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
onClosePane,
|
onClosePane,
|
||||||
}: WorkspacePaneEmptyStateProps): ReactElement {
|
}: WorkspacePaneEmptyStateProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const containerRef = useRef<View | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || !isFocused) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!(container instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const rows = Array.from(container.querySelectorAll<HTMLElement>(LAUNCHER_ROW_SELECTOR));
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const currentIndex = rows.findIndex((row) => row === document.activeElement);
|
||||||
|
let nextIndex: number | null = null;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % rows.length;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
nextIndex =
|
||||||
|
currentIndex < 0 ? rows.length - 1 : (currentIndex - 1 + rows.length) % rows.length;
|
||||||
|
}
|
||||||
|
if (nextIndex !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
rows[nextIndex]?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && currentIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
rows[currentIndex]?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("keydown", handleKeyDown);
|
||||||
|
container.querySelector<HTMLElement>(LAUNCHER_ROW_SELECTOR)?.focus({ preventScroll: true });
|
||||||
|
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isFocused]);
|
||||||
|
|
||||||
const openFiles = useCallback(
|
const openFiles = useCallback(
|
||||||
() => onOpenView({ paneId, target: FILES_TARGET }),
|
() => onOpenView({ paneId, target: FILES_TARGET }),
|
||||||
@@ -119,16 +173,9 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-pane-empty-state">
|
<View ref={containerRef} style={styles.container} testID="workspace-pane-empty-state">
|
||||||
<View style={styles.rail}>
|
<View style={styles.rail}>
|
||||||
<View style={styles.stack}>
|
<View style={styles.stack}>
|
||||||
<LauncherRow
|
|
||||||
label={t("workspace.tabs.actions.files")}
|
|
||||||
icon={FILES_ICON}
|
|
||||||
onPress={openFiles}
|
|
||||||
>
|
|
||||||
<LauncherShortcut actionId="workspace-tab-target-files" />
|
|
||||||
</LauncherRow>
|
|
||||||
{showChanges ? (
|
{showChanges ? (
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.changes")}
|
label={t("workspace.tabs.actions.changes")}
|
||||||
@@ -138,6 +185,13 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
<LauncherShortcut actionId="workspace-tab-target-changes" />
|
<LauncherShortcut actionId="workspace-tab-target-changes" />
|
||||||
</LauncherRow>
|
</LauncherRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
<LauncherRow
|
||||||
|
label={t("workspace.tabs.actions.files")}
|
||||||
|
icon={FILES_ICON}
|
||||||
|
onPress={openFiles}
|
||||||
|
>
|
||||||
|
<LauncherShortcut actionId="workspace-tab-target-files" />
|
||||||
|
</LauncherRow>
|
||||||
{showPullRequest ? (
|
{showPullRequest ? (
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.pullRequest")}
|
label={t("workspace.tabs.actions.pullRequest")}
|
||||||
@@ -146,14 +200,14 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.newAgent")}
|
label={t("workspace.tabs.fallback.agent")}
|
||||||
icon={AGENT_ICON}
|
icon={AGENT_ICON}
|
||||||
onPress={createAgent}
|
onPress={createAgent}
|
||||||
>
|
>
|
||||||
<LauncherShortcut actionId="workspace-tab-target-agent" />
|
<LauncherShortcut actionId="workspace-tab-target-agent" />
|
||||||
</LauncherRow>
|
</LauncherRow>
|
||||||
<LauncherRow
|
<LauncherRow
|
||||||
label={t("workspace.tabs.actions.newTerminal")}
|
label={t("workspace.tabs.fallback.terminal")}
|
||||||
icon={TERMINAL_ICON}
|
icon={TERMINAL_ICON}
|
||||||
onPress={createTerminal}
|
onPress={createTerminal}
|
||||||
>
|
>
|
||||||
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
rowHovered: {
|
rowHovered: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
rowFocused: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
|
|||||||
import {
|
import {
|
||||||
isSidePanelOpen,
|
isSidePanelOpen,
|
||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
|
openTabInSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
useIsSidePanelOpen,
|
useIsSidePanelOpen,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
@@ -76,7 +77,10 @@ import {
|
|||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type {
|
||||||
|
KeyboardActionDefinition,
|
||||||
|
WorkspacePanelTarget,
|
||||||
|
} from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import {
|
||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
@@ -1423,6 +1427,17 @@ function useWorkspaceTerminalTabActions({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCommandCenterPanelTarget(target: WorkspacePanelTarget): WorkspaceTabTarget {
|
||||||
|
switch (target) {
|
||||||
|
case "changes":
|
||||||
|
return { kind: "working_diff" };
|
||||||
|
case "files":
|
||||||
|
return { kind: "files" };
|
||||||
|
case "pull-request":
|
||||||
|
return { kind: "pull_request" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceScreenContent({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -2842,6 +2857,109 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleWorkspacePanelOpenAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
if (action.id !== "workspace.tab.open") return false;
|
||||||
|
if (!persistenceKey) return true;
|
||||||
|
|
||||||
|
const target = resolveCommandCenterPanelTarget(action.target);
|
||||||
|
if (action.placement === "supporting") {
|
||||||
|
openSupportingTab({
|
||||||
|
isCompact: isMobile,
|
||||||
|
workspaceKey: persistenceKey,
|
||||||
|
target,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.placement === "side-panel") {
|
||||||
|
openTabInSidePanel({
|
||||||
|
isCompact: isMobile,
|
||||||
|
workspaceKey: persistenceKey,
|
||||||
|
checkout: activeExplorerCheckout,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const focusedPaneId = focusedPaneTabState.pane?.id;
|
||||||
|
openWorkspaceTabFocused(
|
||||||
|
persistenceKey,
|
||||||
|
target,
|
||||||
|
focusedPaneId ? { mode: "pane", paneId: focusedPaneId } : undefined,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeExplorerCheckout,
|
||||||
|
focusedPaneTabState.pane?.id,
|
||||||
|
isMobile,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
openWorkspaceTabFocused,
|
||||||
|
persistenceKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceCurrentTabMetadataAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
const descriptor = activeTab?.descriptor;
|
||||||
|
switch (action.id) {
|
||||||
|
case "workspace.tab.rename-current":
|
||||||
|
if (descriptor) handleRenameTab(descriptor);
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.reload-current":
|
||||||
|
if (descriptor?.target.kind === "agent")
|
||||||
|
void handleReloadAgent(descriptor.target.agentId);
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-resume-command":
|
||||||
|
if (descriptor?.target.kind === "agent") {
|
||||||
|
void handleCopyResumeCommand(descriptor.target.agentId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-id":
|
||||||
|
if (descriptor?.target.kind === "agent")
|
||||||
|
void handleCopyAgentId(descriptor.target.agentId);
|
||||||
|
if (descriptor?.target.kind === "terminal") {
|
||||||
|
void handleCopyTerminalId(descriptor.target.terminalId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-file-path":
|
||||||
|
if (descriptor?.target.kind === "file") void handleCopyFilePath(descriptor.target.path);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeTab,
|
||||||
|
handleCopyAgentId,
|
||||||
|
handleCopyFilePath,
|
||||||
|
handleCopyResumeCommand,
|
||||||
|
handleCopyTerminalId,
|
||||||
|
handleReloadAgent,
|
||||||
|
handleRenameTab,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceCurrentTabCloseAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
if (!activeTabId) return true;
|
||||||
|
if (action.id === "workspace.tab.close-left") {
|
||||||
|
void handleCloseTabsToLeft(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.id === "workspace.tab.close-right") {
|
||||||
|
void handleCloseTabsToRight(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.id === "workspace.tab.close-others") {
|
||||||
|
void handleCloseOtherTabs(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[activeTabId, handleCloseOtherTabs, handleCloseTabsToLeft, handleCloseTabsToRight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleWorkspaceTabAction = useCallback(
|
const handleWorkspaceTabAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const workspaceKeyboardActionsEnabled = Boolean(
|
||||||
|
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-tab-actions",
|
name: "workspace-tab-actions",
|
||||||
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.terminal.new",
|
"workspace.terminal.new",
|
||||||
"workspace.browser.new",
|
"workspace.browser.new",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceTabAction,
|
handle: handleWorkspaceTabAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-panel-open-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: ["workspace.tab.open"] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspacePanelOpenAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-current-tab-metadata-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: [
|
||||||
|
"workspace.tab.rename-current",
|
||||||
|
"workspace.tab.reload-current",
|
||||||
|
"workspace.tab.copy-resume-command",
|
||||||
|
"workspace.tab.copy-id",
|
||||||
|
"workspace.tab.copy-file-path",
|
||||||
|
] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspaceCurrentTabMetadataAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-current-tab-close-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: [
|
||||||
|
"workspace.tab.close-left",
|
||||||
|
"workspace.tab.close-right",
|
||||||
|
"workspace.tab.close-others",
|
||||||
|
] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspaceCurrentTabCloseAction,
|
||||||
|
});
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-pane-actions",
|
name: "workspace-pane-actions",
|
||||||
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.pane.close",
|
"workspace.pane.close",
|
||||||
"workspace.focus.toggle",
|
"workspace.focus.toggle",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspacePaneAction,
|
handle: handleWorkspacePaneAction,
|
||||||
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
}),
|
}),
|
||||||
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceSidebarAction,
|
handle: handleWorkspaceSidebarAction,
|
||||||
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
|
|||||||
function renderSplitPaneEmptyState(input: {
|
function renderSplitPaneEmptyState(input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Same predicate the close action reads, so the button is offered exactly when
|
// Same predicate the close action reads, so the button is offered exactly when
|
||||||
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
|
|||||||
return (
|
return (
|
||||||
<WorkspacePaneEmptyState
|
<WorkspacePaneEmptyState
|
||||||
paneId={input.paneId}
|
paneId={input.paneId}
|
||||||
|
isFocused={input.isFocused}
|
||||||
showChanges={isGitCheckout}
|
showChanges={isGitCheckout}
|
||||||
showPullRequest={hasPullRequest}
|
showPullRequest={hasPullRequest}
|
||||||
onOpenView={handleOpenPaneView}
|
onOpenView={handleOpenPaneView}
|
||||||
|
|||||||
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let tabId = input.createTabId();
|
let tabId = input.createTabId();
|
||||||
if (currentTab?.target.kind === "draft") {
|
// File navigation is a retarget of one File tab, not a new panel instance. Keeping its
|
||||||
|
// identity preserves the mounted tab track and the File tab's local presentation state.
|
||||||
|
if (
|
||||||
|
currentTab?.target.kind === "draft" ||
|
||||||
|
(currentTab?.target.kind === "file" && input.target.kind === "file")
|
||||||
|
) {
|
||||||
tabId = input.tabId;
|
tabId = input.tabId;
|
||||||
} else if (input.target.kind === "draft") {
|
} else if (input.target.kind === "draft") {
|
||||||
tabId = buildDeterministicWorkspaceTabId(input.target);
|
tabId = buildDeterministicWorkspaceTabId(input.target);
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
||||||
store.setTabState(workspaceKey, file, fileState);
|
store.setTabState(workspaceKey, file, fileState);
|
||||||
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
||||||
|
expect(sameKind).toBe(file);
|
||||||
expect(
|
expect(
|
||||||
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
||||||
(tab) => tab.tabId === sameKind,
|
(tab) => tab.tabId === sameKind,
|
||||||
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
|
|||||||
{ kind: "file", path: "/repo/b.ts" },
|
{ kind: "file", path: "/repo/b.ts" },
|
||||||
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
||||||
);
|
);
|
||||||
expect(replacement).not.toBe(file);
|
expect(replacement).toBe(file);
|
||||||
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined();
|
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
|
|
||||||
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
|
|
||||||
|
|
||||||
store.closeTab(workspaceKey, replacement as string);
|
store.closeTab(workspaceKey, replacement as string);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
showSidePanel,
|
showSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
|
toggleSupportingTab,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const WORKSPACE_KEY = "server-1:ws-main";
|
const WORKSPACE_KEY = "server-1:ws-main";
|
||||||
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
|
|||||||
expect(isSidePanelOpen(wide)).toBe(false);
|
expect(isSidePanelOpen(wide)).toBe(false);
|
||||||
expect(tabKinds()).toEqual(["files"]);
|
expect(tabKinds()).toEqual(["files"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles the visible supporting target without closing its tab", () => {
|
||||||
|
const changes = {
|
||||||
|
...wide,
|
||||||
|
target: { kind: "working_diff" } as const,
|
||||||
|
openInSidePanelByDefault: true,
|
||||||
|
};
|
||||||
|
const tabId = toggleSupportingTab(changes);
|
||||||
|
openSidePanelView({ ...wide, view: "files" });
|
||||||
|
|
||||||
|
toggleSupportingTab(changes);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(true);
|
||||||
|
expect(focusedPaneTabKinds()).toContain("working_diff");
|
||||||
|
|
||||||
|
toggleSupportingTab(changes);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(false);
|
||||||
|
expect(tabKinds()).toContain("working_diff");
|
||||||
|
|
||||||
|
expect(toggleSupportingTab(changes)).toBe(tabId);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("non-compact without splits", () => {
|
describe("non-compact without splits", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The side panel is the companion surface beside the user's work — Changes, Files,
|
* The side panel is the companion surface beside the user's work — Changes, Files,
|
||||||
@@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean {
|
|||||||
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
|
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTargetVisibleInSidePanel(
|
||||||
|
state: LayoutState,
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!selectIsSidePanelVisible(state, workspaceKey)) return false;
|
||||||
|
const layout = state.layoutByWorkspace[workspaceKey];
|
||||||
|
const paneId = selectSidePanelPaneId(state, workspaceKey);
|
||||||
|
const focusedTabId = layout && paneId ? findPaneById(layout.root, paneId)?.focusedTabId : null;
|
||||||
|
if (!layout || !focusedTabId) return false;
|
||||||
|
const focusedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === focusedTabId);
|
||||||
|
return Boolean(focusedTab && workspaceTabTargetsEqual(focusedTab.target, target));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
|
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
|
|||||||
background?: boolean;
|
background?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToggleSupportingTabInput extends SidePanelQuery {
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
openInSidePanelByDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a tab the user did not place themselves — an agent's file link, a detected
|
* Opens a tab the user did not place themselves — an agent's file link, a detected
|
||||||
* pull request, workspace setup progress. A supporting tab that is already open is
|
* pull request, workspace setup progress. A supporting tab that is already open is
|
||||||
@@ -123,6 +143,52 @@ export function openSupportingTab(input: OpenSupportingTabInput): string | null
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle a supporting target when it is the visible Side panel tab; otherwise reveal it. */
|
||||||
|
export function toggleSupportingTab(input: ToggleSupportingTabInput): string | null {
|
||||||
|
if (!input.workspaceKey) return null;
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
if (
|
||||||
|
input.openInSidePanelByDefault &&
|
||||||
|
canUseSidePanelPane(input) &&
|
||||||
|
isTargetVisibleInSidePanel(store, input.workspaceKey, input.target)
|
||||||
|
) {
|
||||||
|
store.hideSidePanel(input.workspaceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return openSupportingTab(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */
|
||||||
|
export function openTabInSidePanel(
|
||||||
|
input: SidePanelInput & { target: WorkspaceTabTarget },
|
||||||
|
): string | null {
|
||||||
|
if (!input.workspaceKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isCompact) {
|
||||||
|
const view = Object.entries(SIDE_PANEL_VIEW_TARGETS).find(
|
||||||
|
([, target]) => target.kind === input.target.kind,
|
||||||
|
)?.[0] as ExplorerTab | undefined;
|
||||||
|
if (!view || !input.checkout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const panel = usePanelStore.getState();
|
||||||
|
panel.setExplorerTabForCheckout({ ...input.checkout, tab: view });
|
||||||
|
panel.openCompactFileExplorer(input.checkout);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
const paneId = canUseSidePanelPane(input) ? store.showSidePanel(input.workspaceKey) : undefined;
|
||||||
|
return store.openTab({
|
||||||
|
workspaceKey: input.workspaceKey,
|
||||||
|
target: input.target,
|
||||||
|
intent: "reveal",
|
||||||
|
placement: paneId ? { mode: "pane", paneId } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving
|
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving
|
||||||
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
|
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe("Hub commands", () => {
|
|||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
"progress:Logging in to https://hub.paseo.sh",
|
"progress:Logging in to https://hub.paseo.sh",
|
||||||
"authorize:https://hub.paseo.sh",
|
"authorize:https://hub.paseo.sh",
|
||||||
|
"progress:Logged in",
|
||||||
]);
|
]);
|
||||||
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
||||||
});
|
});
|
||||||
@@ -113,11 +114,18 @@ describe("Hub commands", () => {
|
|||||||
events.push("init");
|
events.push("init");
|
||||||
events.push("deploy");
|
events.push("deploy");
|
||||||
},
|
},
|
||||||
reporter: quietReporter,
|
reporter: { progress: (message) => events.push(`progress:${message}`) },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(events, ["login", "connect:https://hub.test", "init", "deploy"]);
|
assert.deepEqual(events, [
|
||||||
|
"progress:Logging in to https://hub.test",
|
||||||
|
"login",
|
||||||
|
"progress:Logged in",
|
||||||
|
"connect:https://hub.test",
|
||||||
|
"init",
|
||||||
|
"deploy",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("JSON and noninteractive login remain login-only", async () => {
|
it("JSON and noninteractive login remain login-only", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, it } from "vitest";
|
import { afterEach, describe, it } from "vitest";
|
||||||
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
||||||
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
||||||
import type { HubHttpClient } from "./hub-client/index.js";
|
import type { HubHttpClient } from "./hub-client/index.js";
|
||||||
@@ -24,11 +25,7 @@ describe("Hub guided setup continuation", () => {
|
|||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
const credentials = new MemoryCredentials();
|
const credentials = new MemoryCredentials();
|
||||||
const daemon = new SetupDaemon();
|
const daemon = new SetupDaemon();
|
||||||
const prompts = new PromptAnswers(
|
const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
|
||||||
[true, true],
|
|
||||||
["codex", "gpt-5", "full-access", "slack"],
|
|
||||||
["U123"],
|
|
||||||
);
|
|
||||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||||
|
|
||||||
@@ -46,24 +43,22 @@ describe("Hub guided setup continuation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(prompts.confirmations, [
|
assert.deepEqual(prompts.confirmations, [
|
||||||
"Connect this daemon to https://hub.test?",
|
"Connect this daemon to this Hub?",
|
||||||
"Initialize and deploy a starter workflow?",
|
"Initialize and deploy a starter workflow?",
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(prompts.selections, [
|
assert.deepEqual(prompts.selections, [
|
||||||
"Starter agent provider",
|
"Starter agent provider",
|
||||||
"Starter agent model",
|
"Starter agent model",
|
||||||
"Starter agent mode",
|
"Starter agent mode",
|
||||||
"Trigger provider",
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(prompts.selectionOptions, [
|
assert.deepEqual(prompts.selectionOptions, [
|
||||||
["Codex (suggested)"],
|
["Codex"],
|
||||||
["GPT-5 (suggested)", "GPT-5 mini"],
|
["GPT-5 (suggested)", "GPT-5 mini"],
|
||||||
["Read only", "Full access (suggested)"],
|
["Read only", "Full access (suggested)"],
|
||||||
[
|
]);
|
||||||
"GitHub (issue or pull request comment)",
|
assert.deepEqual(prompts.messages, [
|
||||||
"Slack (channel mention)",
|
"Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.",
|
||||||
"Discord (channel mention)",
|
"Using Slack — Paseo",
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ operation: "token", origin: "https://hub.test" },
|
{ operation: "token", origin: "https://hub.test" },
|
||||||
@@ -111,6 +106,36 @@ describe("Hub guided setup continuation", () => {
|
|||||||
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
|
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps login successful when no Hub app connection can initialize a workflow", async () => {
|
||||||
|
const cwd = await temporaryDirectory();
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
const daemon = new SetupDaemon();
|
||||||
|
const prompts = new PromptAnswers([true, true], [], []);
|
||||||
|
|
||||||
|
await runHubLogin(
|
||||||
|
"https://hub.test",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
env: {},
|
||||||
|
credentials,
|
||||||
|
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
|
||||||
|
isInteractive: () => true,
|
||||||
|
continueGuidedSetup: (origin) =>
|
||||||
|
continueHubGuidedSetup(
|
||||||
|
origin,
|
||||||
|
setupEnvironment(cwd, credentials, daemon, prompts, [], {
|
||||||
|
setupResources: { github: [], slack: [], discord: [] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
reporter: { progress() {} },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(prompts.messages, [
|
||||||
|
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps hub init's existing replacement confirmation", async () => {
|
it("keeps hub init's existing replacement confirmation", async () => {
|
||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
await mkdir(path.join(cwd, ".paseo"));
|
await mkdir(path.join(cwd, ".paseo"));
|
||||||
@@ -140,7 +165,7 @@ describe("Hub guided setup continuation", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto", "slack"], ["U123"]);
|
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto"], ["U123"]);
|
||||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||||
|
|
||||||
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
||||||
@@ -162,6 +187,52 @@ describe("Hub guided setup continuation", () => {
|
|||||||
["validate", "install"],
|
["validate", "install"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => {
|
||||||
|
const cwd = await temporaryDirectory();
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||||
|
const daemon = new SetupDaemon();
|
||||||
|
const prompts = new PromptAnswers([], [], []);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
runHubGuidedSetup(
|
||||||
|
setupEnvironment(cwd, credentials, daemon, prompts, [], {
|
||||||
|
setupResources: { github: [], slack: [], discord: [] },
|
||||||
|
}),
|
||||||
|
{ origin: "https://hub.test", daemonId: "daemon-1", deploy: true },
|
||||||
|
),
|
||||||
|
/No Hub app connection is ready for this workflow/u,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(daemon.snapshotCwds.length, 0);
|
||||||
|
await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for fresh-daemon provider discovery before offering runtime choices", async () => {
|
||||||
|
const cwd = await temporaryDirectory();
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||||
|
const ready = new SetupDaemon().providerEntries;
|
||||||
|
const daemon = new SetupDaemon(ready, [
|
||||||
|
[{ provider: "codex", status: "loading", enabled: true }],
|
||||||
|
ready,
|
||||||
|
]);
|
||||||
|
const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]);
|
||||||
|
|
||||||
|
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), {
|
||||||
|
origin: "https://hub.test",
|
||||||
|
daemonId: "daemon-1",
|
||||||
|
deploy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(daemon.snapshotCwds.length, 2);
|
||||||
|
assert.deepEqual(prompts.selections.slice(0, 3), [
|
||||||
|
"Starter agent provider",
|
||||||
|
"Starter agent model",
|
||||||
|
"Starter agent mode",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupEnvironment(
|
function setupEnvironment(
|
||||||
@@ -170,6 +241,9 @@ function setupEnvironment(
|
|||||||
daemon: SetupDaemon,
|
daemon: SetupDaemon,
|
||||||
prompts: PromptAnswers,
|
prompts: PromptAnswers,
|
||||||
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
||||||
|
options: {
|
||||||
|
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
|
||||||
|
} = {},
|
||||||
): HubGuidedSetupEnvironment {
|
): HubGuidedSetupEnvironment {
|
||||||
const hub = {
|
const hub = {
|
||||||
async issueEnrollmentToken(origin: string) {
|
async issueEnrollmentToken(origin: string) {
|
||||||
@@ -182,11 +256,13 @@ function setupEnvironment(
|
|||||||
},
|
},
|
||||||
async listSetupResources(origin: string) {
|
async listSetupResources(origin: string) {
|
||||||
calls.push({ operation: "setup", origin });
|
calls.push({ operation: "setup", origin });
|
||||||
return {
|
return (
|
||||||
|
options.setupResources ?? {
|
||||||
github: [],
|
github: [],
|
||||||
discord: [],
|
discord: [],
|
||||||
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
||||||
};
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async listConfigurationResources(origin: string) {
|
async listConfigurationResources(origin: string) {
|
||||||
calls.push({ operation: "resources", origin });
|
calls.push({ operation: "resources", origin });
|
||||||
@@ -294,7 +370,7 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
private origin: string | null = null;
|
private origin: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly providerEntries = [
|
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
status: "ready" as const,
|
status: "ready" as const,
|
||||||
@@ -311,6 +387,7 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
defaultModeId: "full-access",
|
defaultModeId: "full-access",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async connectHub(origin: string) {
|
async connectHub(origin: string) {
|
||||||
@@ -325,7 +402,11 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
|
|
||||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||||
this.snapshotCwds.push(options?.cwd);
|
this.snapshotCwds.push(options?.cwd);
|
||||||
return { entries: this.providerEntries };
|
const index = Math.min(
|
||||||
|
this.snapshotCwds.length - 1,
|
||||||
|
(this.providerEntrySequence?.length ?? 1) - 1,
|
||||||
|
);
|
||||||
|
return { entries: this.providerEntrySequence?.[index] ?? this.providerEntries };
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnectHub() {
|
async disconnectHub() {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
planHubInitOpening,
|
planHubInitOpening,
|
||||||
resolveHubInitConnection,
|
resolveHubInitConnection,
|
||||||
resolveHubInitProjects,
|
resolveHubInitProjects,
|
||||||
type HubInitProvider,
|
|
||||||
} from "./init-plan.js";
|
} from "./init-plan.js";
|
||||||
import type { CliLoginFlow } from "./login-flow.js";
|
import type { CliLoginFlow } from "./login-flow.js";
|
||||||
import { runHubConnect } from "./connect.js";
|
import { runHubConnect } from "./connect.js";
|
||||||
@@ -43,16 +42,21 @@ import { runHubProjects } from "./projects.js";
|
|||||||
import type { HubReporter } from "./reporter.js";
|
import type { HubReporter } from "./reporter.js";
|
||||||
import { normalizeHubOrigin } from "./origin.js";
|
import { normalizeHubOrigin } from "./origin.js";
|
||||||
import {
|
import {
|
||||||
availableStarterAgentProviders,
|
|
||||||
selectedStarterAgentRuntime,
|
selectedStarterAgentRuntime,
|
||||||
|
starterAgentProviderSnapshotState,
|
||||||
suggestedStarterAgentChoice,
|
suggestedStarterAgentChoice,
|
||||||
type HubStarterAgentProvider,
|
type HubStarterAgentProvider,
|
||||||
type HubStarterAgentRuntime,
|
type HubStarterAgentRuntime,
|
||||||
} from "./starter-agent-runtime.js";
|
} from "./starter-agent-runtime.js";
|
||||||
|
import {
|
||||||
|
availableStarterTriggerConnections,
|
||||||
|
type HubStarterTriggerConnection,
|
||||||
|
} from "./starter-trigger.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
||||||
const DAEMON_READY_POLL_MS = 250;
|
const DAEMON_READY_POLL_MS = 250;
|
||||||
|
const PROVIDER_READY_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export interface HubGuidedSetupEnvironment {
|
export interface HubGuidedSetupEnvironment {
|
||||||
env: Readonly<Record<string, string | undefined>>;
|
env: Readonly<Record<string, string | undefined>>;
|
||||||
@@ -132,16 +136,18 @@ export async function runHubGuidedSetup(
|
|||||||
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
|
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
|
||||||
const project = await chooseProject(origin, environment);
|
const project = await chooseProject(origin, environment);
|
||||||
const resources = await loadSetupResources(origin, environment);
|
const resources = await loadSetupResources(origin, environment);
|
||||||
|
const triggerConnections = await resolveStarterTriggerConnections(resources, cwd);
|
||||||
|
reportStarterTriggerConnections(environment, triggerConnections);
|
||||||
|
const trigger = await chooseStarterTriggerConnection(environment, triggerConnections);
|
||||||
const daemon = await loadDaemonResource(origin, daemonId, environment);
|
const daemon = await loadDaemonResource(origin, daemonId, environment);
|
||||||
log.success(`Connected as ${daemon.slug}`);
|
log.success(`Connected as ${daemon.slug}`);
|
||||||
const agent = await chooseStarterAgentRuntime(environment, cwd);
|
const agent = await chooseStarterAgentRuntime(environment, cwd);
|
||||||
const provider = await chooseProvider(environment);
|
const providerFilters = await collectProviderIdentity(trigger, environment);
|
||||||
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
|
|
||||||
const scaffold = createHubInitScaffold({
|
const scaffold = createHubInitScaffold({
|
||||||
cwd,
|
cwd,
|
||||||
daemonSlug: daemon.slug,
|
daemonSlug: daemon.slug,
|
||||||
agent,
|
agent,
|
||||||
provider,
|
provider: trigger.provider,
|
||||||
providerFilters,
|
providerFilters,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +191,7 @@ export async function continueHubGuidedSetup(
|
|||||||
origin: string,
|
origin: string,
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
|
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
|
||||||
reportMessage(
|
reportMessage(
|
||||||
environment,
|
environment,
|
||||||
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
||||||
@@ -200,7 +206,20 @@ export async function continueHubGuidedSetup(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
|
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HubInitCancelledError) {
|
||||||
|
if (environment.prompts === undefined) {
|
||||||
|
log.message(error.message);
|
||||||
|
outro("Connect an app to continue");
|
||||||
|
} else {
|
||||||
|
environment.prompts.message(error.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureLogin(
|
async function ensureLogin(
|
||||||
@@ -370,31 +389,61 @@ async function chooseProject(
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
|
async function resolveStarterTriggerConnections(
|
||||||
return requiredSelect(environment, {
|
resources: HubSetupResources,
|
||||||
message: "Trigger provider",
|
cwd: string,
|
||||||
options: [
|
): Promise<HubStarterTriggerConnection[]> {
|
||||||
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
|
const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
|
||||||
{ value: "slack", label: "Slack", hint: "channel mention" },
|
const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
|
||||||
{ value: "discord", label: "Discord", hint: "channel mention" },
|
const connections = availableStarterTriggerConnections(resources, repository);
|
||||||
],
|
if (connections.length === 0) {
|
||||||
|
throw new HubInitCancelledError(
|
||||||
|
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportStarterTriggerConnections(
|
||||||
|
environment: HubGuidedSetupEnvironment,
|
||||||
|
connections: readonly HubStarterTriggerConnection[],
|
||||||
|
): void {
|
||||||
|
const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`;
|
||||||
|
if (environment.prompts === undefined) {
|
||||||
|
note(details, "Hub app connections ready for this workflow");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseStarterTriggerConnection(
|
||||||
|
environment: HubGuidedSetupEnvironment,
|
||||||
|
connections: readonly HubStarterTriggerConnection[],
|
||||||
|
): Promise<HubStarterTriggerConnection> {
|
||||||
|
if (connections.length === 1) {
|
||||||
|
const connection = connections[0]!;
|
||||||
|
reportMessage(environment, `Using ${connection.label}`);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
const id = await requiredSelect(environment, {
|
||||||
|
message: "Trigger connection",
|
||||||
|
options: connections.map((connection) => ({ value: connection.id, label: connection.label })),
|
||||||
});
|
});
|
||||||
|
const connection = connections.find((candidate) => candidate.id === id);
|
||||||
|
if (connection === undefined) {
|
||||||
|
throw new HubCommandError(
|
||||||
|
"HUB_PROVIDER_CONNECTION_INVALID",
|
||||||
|
"The selected Hub app connection is no longer available. Run paseo hub init again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseStarterAgentRuntime(
|
async function chooseStarterAgentRuntime(
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<HubStarterAgentRuntime> {
|
): Promise<HubStarterAgentRuntime> {
|
||||||
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) =>
|
const providers = await waitForStarterAgentProviders(environment, cwd);
|
||||||
daemon.getProvidersSnapshot({ cwd }),
|
|
||||||
);
|
|
||||||
const providers = availableStarterAgentProviders(snapshot.entries);
|
|
||||||
if (providers.length === 0) {
|
|
||||||
throw new HubCommandError(
|
|
||||||
"HUB_AGENT_RUNTIME_REQUIRED",
|
|
||||||
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const provider = await chooseStarterAgentProvider(environment, providers);
|
const provider = await chooseStarterAgentProvider(environment, providers);
|
||||||
const model = await chooseStarterAgentModel(environment, provider);
|
const model = await chooseStarterAgentModel(environment, provider);
|
||||||
const mode = await chooseStarterAgentMode(environment, provider);
|
const mode = await chooseStarterAgentMode(environment, provider);
|
||||||
@@ -408,18 +457,45 @@ async function chooseStarterAgentRuntime(
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForStarterAgentProviders(
|
||||||
|
environment: HubGuidedSetupEnvironment,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<readonly HubStarterAgentProvider[]> {
|
||||||
|
return withSpinner("Discovering agent runtimes", async (reporter) =>
|
||||||
|
withHubDaemon(environment.daemon, undefined, async (daemon) => {
|
||||||
|
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
|
||||||
|
while (true) {
|
||||||
|
const snapshot = await daemon.getProvidersSnapshot({ cwd });
|
||||||
|
const state = starterAgentProviderSnapshotState(snapshot.entries);
|
||||||
|
if (state.kind === "ready") return state.providers;
|
||||||
|
if (state.kind === "unavailable") {
|
||||||
|
throw new HubCommandError(
|
||||||
|
"HUB_AGENT_RUNTIME_REQUIRED",
|
||||||
|
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw new HubCommandError(
|
||||||
|
"HUB_AGENT_RUNTIME_TIMEOUT",
|
||||||
|
"Agent runtime discovery did not finish within 60 seconds. Check the daemon's provider configuration, then run paseo hub init again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reporter.progress("Waiting for agent runtime discovery");
|
||||||
|
await delay(DAEMON_READY_POLL_MS);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseStarterAgentProvider(
|
async function chooseStarterAgentProvider(
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
providers: readonly HubStarterAgentProvider[],
|
providers: readonly HubStarterAgentProvider[],
|
||||||
): Promise<HubStarterAgentProvider> {
|
): Promise<HubStarterAgentProvider> {
|
||||||
const suggested = suggestedStarterAgentChoice(providers);
|
|
||||||
const selected = await requiredSelect(environment, {
|
const selected = await requiredSelect(environment, {
|
||||||
message: "Starter agent provider",
|
message: "Starter agent provider",
|
||||||
...(suggested === undefined ? {} : { initialValue: suggested.id }),
|
|
||||||
options: providers.map((provider) => ({
|
options: providers.map((provider) => ({
|
||||||
value: provider.id,
|
value: provider.id,
|
||||||
label: provider.label,
|
label: provider.label,
|
||||||
...(provider.suggested ? { hint: "suggested" } : {}),
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const provider = providers.find((candidate) => candidate.id === selected);
|
const provider = providers.find((candidate) => candidate.id === selected);
|
||||||
@@ -471,57 +547,30 @@ function invalidStarterAgentSelection(): HubCommandError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectProviderFilters(
|
async function collectProviderIdentity(
|
||||||
provider: HubInitProvider,
|
trigger: HubStarterTriggerConnection,
|
||||||
resources: HubSetupResources,
|
|
||||||
cwd: string,
|
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
): Promise<Readonly<Record<string, string>>> {
|
): Promise<Readonly<Record<string, string>>> {
|
||||||
if (provider === "github") {
|
if (trigger.provider === "github") {
|
||||||
const [login, originRemote] = await Promise.all([
|
const login = await readGhValue(["api", "user", "--jq", ".login"]);
|
||||||
readGhValue(["api", "user", "--jq", ".login"]),
|
|
||||||
readCommandValue("git", ["remote", "get-url", "origin"], cwd),
|
|
||||||
]);
|
|
||||||
const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
|
|
||||||
if (repo === undefined) {
|
|
||||||
throw new HubCommandError(
|
|
||||||
"HUB_GITHUB_REPOSITORY_UNDETECTED",
|
|
||||||
"Could not detect a GitHub repository from the origin remote.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
|
|
||||||
const connected = resources.github.flatMap(({ repositories }) => repositories);
|
|
||||||
throw new HubCommandError(
|
|
||||||
"HUB_GITHUB_REPOSITORY_NOT_CONNECTED",
|
|
||||||
`${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
|
...trigger.filters,
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your GitHub username (only this user can trigger the bot)",
|
message: "Your GitHub username (only this user can trigger the bot)",
|
||||||
initialValue: login,
|
initialValue: login,
|
||||||
}),
|
}),
|
||||||
repo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (provider === "slack") {
|
if (trigger.provider === "slack") {
|
||||||
return {
|
return {
|
||||||
workspace: await chooseConnection(
|
...trigger.filters,
|
||||||
environment,
|
|
||||||
"Slack workspace",
|
|
||||||
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
|
|
||||||
),
|
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your Slack member ID (only this user can trigger the bot)",
|
message: "Your Slack member ID (only this user can trigger the bot)",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
guild: await chooseConnection(
|
...trigger.filters,
|
||||||
environment,
|
|
||||||
"Discord server",
|
|
||||||
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
|
|
||||||
),
|
|
||||||
user: await requiredText(environment, {
|
user: await requiredText(environment, {
|
||||||
message: "Your Discord user ID (only this user can trigger the bot)",
|
message: "Your Discord user ID (only this user can trigger the bot)",
|
||||||
}),
|
}),
|
||||||
@@ -579,24 +628,6 @@ async function loadDaemonResource(
|
|||||||
return daemon;
|
return daemon;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseConnection(
|
|
||||||
environment: HubGuidedSetupEnvironment,
|
|
||||||
message: string,
|
|
||||||
connections: readonly { id: string; label: string }[],
|
|
||||||
): Promise<string> {
|
|
||||||
if (connections.length === 0) {
|
|
||||||
throw new HubCommandError(
|
|
||||||
"HUB_PROVIDER_CONNECTION_REQUIRED",
|
|
||||||
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (connections.length === 1) return connections[0]!.id;
|
|
||||||
return requiredSelect(environment, {
|
|
||||||
message,
|
|
||||||
options: connections.map(({ id, label }) => ({ value: id, label })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeScaffold(
|
async function writeScaffold(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
scaffold: ReturnType<typeof createHubInitScaffold>,
|
scaffold: ReturnType<typeof createHubInitScaffold>,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function runHubLogin(
|
|||||||
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
||||||
const credential = await dependencies.flow.authorize(origin);
|
const credential = await dependencies.flow.authorize(origin);
|
||||||
dependencies.credentials.save({ origin, credential });
|
dependencies.credentials.save({ origin, credential });
|
||||||
|
reportHubProgress(dependencies.reporter, options, "Logged in");
|
||||||
if (
|
if (
|
||||||
!options.json &&
|
!options.json &&
|
||||||
dependencies.isInteractive?.() &&
|
dependencies.isInteractive?.() &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
availableStarterAgentProviders,
|
availableStarterAgentProviders,
|
||||||
selectedStarterAgentRuntime,
|
selectedStarterAgentRuntime,
|
||||||
|
starterAgentProviderSnapshotState,
|
||||||
suggestedStarterAgentChoice,
|
suggestedStarterAgentChoice,
|
||||||
} from "./starter-agent-runtime.js";
|
} from "./starter-agent-runtime.js";
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
suggested: true,
|
|
||||||
models: [
|
models: [
|
||||||
{ id: "gpt-5", label: "GPT-5", suggested: true },
|
{ id: "gpt-5", label: "GPT-5", suggested: true },
|
||||||
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
|
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
|
||||||
@@ -59,7 +59,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
label: "Claude",
|
label: "Claude",
|
||||||
suggested: false,
|
|
||||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||||
},
|
},
|
||||||
@@ -81,7 +80,6 @@ describe("starter agent runtime choices", () => {
|
|||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
label: "claude",
|
label: "claude",
|
||||||
suggested: false,
|
|
||||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||||
},
|
},
|
||||||
@@ -118,4 +116,16 @@ describe("starter agent runtime choices", () => {
|
|||||||
});
|
});
|
||||||
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
|
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("distinguishes discovery in progress from a terminally unusable snapshot", () => {
|
||||||
|
expect(starterAgentProviderSnapshotState([])).toEqual({ kind: "loading" });
|
||||||
|
expect(
|
||||||
|
starterAgentProviderSnapshotState([{ provider: "claude", status: "loading", enabled: true }]),
|
||||||
|
).toEqual({ kind: "loading" });
|
||||||
|
expect(
|
||||||
|
starterAgentProviderSnapshotState([
|
||||||
|
{ provider: "claude", status: "unavailable", enabled: true },
|
||||||
|
]),
|
||||||
|
).toEqual({ kind: "unavailable" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface HubStarterAgentProvider {
|
|||||||
label: string;
|
label: string;
|
||||||
models: readonly HubStarterAgentModel[];
|
models: readonly HubStarterAgentModel[];
|
||||||
modes: readonly HubStarterAgentMode[];
|
modes: readonly HubStarterAgentMode[];
|
||||||
suggested: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HubStarterAgentModel {
|
export interface HubStarterAgentModel {
|
||||||
@@ -26,6 +25,11 @@ export interface HubStarterAgentMode {
|
|||||||
suggested: boolean;
|
suggested: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HubStarterAgentProviderSnapshotState =
|
||||||
|
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "unavailable" };
|
||||||
|
|
||||||
export function availableStarterAgentProviders(
|
export function availableStarterAgentProviders(
|
||||||
entries: readonly ProviderSnapshotEntry[],
|
entries: readonly ProviderSnapshotEntry[],
|
||||||
): HubStarterAgentProvider[] {
|
): HubStarterAgentProvider[] {
|
||||||
@@ -47,14 +51,23 @@ export function availableStarterAgentProviders(
|
|||||||
label: entry.label ?? entry.provider,
|
label: entry.label ?? entry.provider,
|
||||||
models,
|
models,
|
||||||
modes,
|
modes,
|
||||||
suggested:
|
|
||||||
models.some((model) => model.suggested) &&
|
|
||||||
(modes.length === 0 || modes.some((mode) => mode.suggested)),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function starterAgentProviderSnapshotState(
|
||||||
|
entries: readonly ProviderSnapshotEntry[],
|
||||||
|
): HubStarterAgentProviderSnapshotState {
|
||||||
|
const providers = availableStarterAgentProviders(entries);
|
||||||
|
if (providers.length > 0) return { kind: "ready", providers };
|
||||||
|
if (entries.length === 0) return { kind: "loading" };
|
||||||
|
if (entries.some((entry) => entry.enabled && entry.status === "loading")) {
|
||||||
|
return { kind: "loading" };
|
||||||
|
}
|
||||||
|
return { kind: "unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
|
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
|
||||||
choices: readonly T[],
|
choices: readonly T[],
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { availableStarterTriggerConnections } from "./starter-trigger.js";
|
||||||
|
|
||||||
|
describe("starter trigger connections", () => {
|
||||||
|
it("returns only concrete connections that can back the generated workflow", () => {
|
||||||
|
expect(
|
||||||
|
availableStarterTriggerConnections(
|
||||||
|
{
|
||||||
|
github: [
|
||||||
|
{
|
||||||
|
slug: "github-getpaseo",
|
||||||
|
accountLogin: "getpaseo",
|
||||||
|
accountType: "Organization",
|
||||||
|
repositories: ["getpaseo/paseo"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
||||||
|
discord: [{ guildId: "456", guildName: "Paseo Discord" }],
|
||||||
|
},
|
||||||
|
"getpaseo/paseo",
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "github:getpaseo/paseo",
|
||||||
|
label: "GitHub — getpaseo/paseo",
|
||||||
|
provider: "github",
|
||||||
|
filters: { repo: "getpaseo/paseo" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack:T123",
|
||||||
|
label: "Slack — Paseo",
|
||||||
|
provider: "slack",
|
||||||
|
filters: { workspace: "T123" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discord:456",
|
||||||
|
label: "Discord — Paseo Discord",
|
||||||
|
provider: "discord",
|
||||||
|
filters: { guild: "456" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer GitHub when the current repository is not connected", () => {
|
||||||
|
expect(
|
||||||
|
availableStarterTriggerConnections(
|
||||||
|
{
|
||||||
|
github: [
|
||||||
|
{
|
||||||
|
slug: "github-getpaseo",
|
||||||
|
accountLogin: "getpaseo",
|
||||||
|
accountType: "Organization",
|
||||||
|
repositories: ["getpaseo/hub"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
slack: [],
|
||||||
|
discord: [],
|
||||||
|
},
|
||||||
|
"getpaseo/paseo",
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { HubSetupResources } from "./hub-client/index.js";
|
||||||
|
import type { HubInitProvider } from "./init-plan.js";
|
||||||
|
|
||||||
|
export interface HubStarterTriggerConnection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: HubInitProvider;
|
||||||
|
filters: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableStarterTriggerConnections(
|
||||||
|
resources: HubSetupResources,
|
||||||
|
githubRepository?: string,
|
||||||
|
): HubStarterTriggerConnection[] {
|
||||||
|
return [
|
||||||
|
...(githubRepository !== undefined &&
|
||||||
|
resources.github.some(({ repositories }) => repositories.includes(githubRepository))
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `github:${githubRepository}`,
|
||||||
|
label: `GitHub — ${githubRepository}`,
|
||||||
|
provider: "github" as const,
|
||||||
|
filters: { repo: githubRepository },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...resources.slack.map(({ teamId, teamName }) => ({
|
||||||
|
id: `slack:${teamId}`,
|
||||||
|
label: `Slack — ${teamName}`,
|
||||||
|
provider: "slack" as const,
|
||||||
|
filters: { workspace: teamId },
|
||||||
|
})),
|
||||||
|
...resources.discord.map(({ guildId, guildName }) => ({
|
||||||
|
id: `discord:${guildId}`,
|
||||||
|
label: `Discord — ${guildName}`,
|
||||||
|
provider: "discord" as const,
|
||||||
|
filters: { guild: guildId },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
|||||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_file_budget_compat",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
|
||||||
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "large.txt",
|
||||||
|
mode: "file",
|
||||||
|
acceptBinary: true,
|
||||||
|
maxBytes: 10,
|
||||||
|
requestId: "req-budget",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-budget",
|
||||||
|
metadata: {
|
||||||
|
mime: "text/plain",
|
||||||
|
size: 100,
|
||||||
|
encoding: "utf-8",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-budget",
|
||||||
|
payload: new Uint8Array(100),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||||
|
});
|
||||||
|
|
||||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
|
|||||||
interface PendingBinaryFileRead {
|
interface PendingBinaryFileRead {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
maxBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
||||||
@@ -4308,6 +4309,7 @@ export class DaemonClient {
|
|||||||
mode: "list" | "file",
|
mode: "list" | "file",
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
acceptBinary = false,
|
acceptBinary = false,
|
||||||
|
maxBytes?: number,
|
||||||
): Promise<FileExplorerPayload> {
|
): Promise<FileExplorerPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
@@ -4317,6 +4319,7 @@ export class DaemonClient {
|
|||||||
path,
|
path,
|
||||||
mode,
|
mode,
|
||||||
...(acceptBinary ? { acceptBinary: true } : {}),
|
...(acceptBinary ? { acceptBinary: true } : {}),
|
||||||
|
...(maxBytes ? { maxBytes } : {}),
|
||||||
},
|
},
|
||||||
responseType: "file_explorer_response",
|
responseType: "file_explorer_response",
|
||||||
});
|
});
|
||||||
@@ -4337,11 +4340,23 @@ export class DaemonClient {
|
|||||||
return payload.directory;
|
return payload.directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
|
async readFile(
|
||||||
|
cwd: string,
|
||||||
|
path: string,
|
||||||
|
requestId?: string,
|
||||||
|
maxBytes?: number,
|
||||||
|
): Promise<FileReadResult> {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||||
try {
|
try {
|
||||||
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
|
const payload = await this.requestFileExplorer(
|
||||||
|
cwd,
|
||||||
|
path,
|
||||||
|
"file",
|
||||||
|
resolvedRequestId,
|
||||||
|
true,
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -5761,10 +5776,33 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
||||||
|
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||||
|
// Old daemons stream despite maxBytes; discard before client-side accumulation.
|
||||||
|
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
transfer.chunks.push(frame.payload);
|
transfer.chunks.push(frame.payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||||
|
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||||
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
|
this.handleSessionMessage({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd: transfer.cwd,
|
||||||
|
path: transfer.path,
|
||||||
|
mode: "file",
|
||||||
|
directory: null,
|
||||||
|
file: null,
|
||||||
|
error: "File is too large to display",
|
||||||
|
requestId: frame.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
this.completedBinaryFileReads.set(frame.requestId, {
|
this.completedBinaryFileReads.set(frame.requestId, {
|
||||||
|
|||||||
@@ -2555,6 +2555,7 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
mode: z.enum(["list", "file"]),
|
mode: z.enum(["list", "file"]),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
acceptBinary: z.boolean().optional(),
|
acceptBinary: z.boolean().optional(),
|
||||||
|
maxBytes: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FileVersionSchema = z.discriminatedUnion("status", [
|
export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string {
|
|||||||
return new TextDecoder().decode(decrypt(sharedKey, ciphertext));
|
return new TextDecoder().decode(decrypt(sharedKey, ciphertext));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bytesFromHex(hex: string): Uint8Array {
|
||||||
|
return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16));
|
||||||
|
}
|
||||||
|
|
||||||
describe("crypto", () => {
|
describe("crypto", () => {
|
||||||
describe("generateKeyPair", () => {
|
describe("generateKeyPair", () => {
|
||||||
it("generates a valid keypair", () => {
|
it("generates a valid keypair", () => {
|
||||||
@@ -36,6 +40,22 @@ describe("crypto", () => {
|
|||||||
const reExported = exportPublicKey(imported);
|
const reExported = exportPublicKey(imported);
|
||||||
expect(reExported).toBe(exported);
|
expect(reExported).toBe(exported);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["malformed", "!".repeat(43) + "="],
|
||||||
|
["noncanonical", `${"A".repeat(42)}B=`],
|
||||||
|
])("rejects %s base64", (_description, encoded) => {
|
||||||
|
expect(importPublicKey.bind(null, encoded)).toThrowError("Invalid public key encoding");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["short", new Uint8Array(31)],
|
||||||
|
["oversized", new Uint8Array(33)],
|
||||||
|
])("rejects a %s decoded key", (_description, key) => {
|
||||||
|
expect(importPublicKey.bind(null, Buffer.from(key).toString("base64"))).toThrowError(
|
||||||
|
"Invalid public key length (expected 32)",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deriveSharedKey", () => {
|
describe("deriveSharedKey", () => {
|
||||||
@@ -63,6 +83,34 @@ describe("crypto", () => {
|
|||||||
|
|
||||||
expect(decrypted).toBe(testMessage);
|
expect(decrypted).toBe(testMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unsupported peer public keys.
|
||||||
|
it.each([
|
||||||
|
["unsupported key 1", "00".repeat(32)],
|
||||||
|
["unsupported key 2", `01${"00".repeat(31)}`],
|
||||||
|
["unsupported key 3", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"],
|
||||||
|
["unsupported key 4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"],
|
||||||
|
["unsupported key 5", `ec${"ff".repeat(30)}7f`],
|
||||||
|
["unsupported key 6", `ed${"ff".repeat(30)}7f`],
|
||||||
|
["unsupported key 7", `ee${"ff".repeat(30)}7f`],
|
||||||
|
])("rejects the %s peer public key", (_description, publicKeyHex) => {
|
||||||
|
const { secretKey } = generateKeyPair();
|
||||||
|
|
||||||
|
expect(deriveSharedKey.bind(null, secretKey, bytesFromHex(publicKeyHex))).toThrowError(
|
||||||
|
"Invalid peer public key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["short", new Uint8Array(31)],
|
||||||
|
["oversized", new Uint8Array(33)],
|
||||||
|
])("rejects a %s peer public key", (_description, publicKey) => {
|
||||||
|
const { secretKey } = generateKeyPair();
|
||||||
|
|
||||||
|
expect(deriveSharedKey.bind(null, secretKey, publicKey)).toThrowError(
|
||||||
|
"Invalid peer public key length (expected 32)",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("encrypt / decrypt", () => {
|
describe("encrypt / decrypt", () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface KeyPair {
|
|||||||
export type SharedKey = Uint8Array; // 32 bytes (box.before)
|
export type SharedKey = Uint8Array; // 32 bytes (box.before)
|
||||||
|
|
||||||
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
||||||
|
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);
|
||||||
|
|
||||||
let prngReady = false;
|
let prngReady = false;
|
||||||
|
|
||||||
@@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array {
|
|||||||
return toByteArray(base64);
|
return toByteArray(base64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodePublicKeyBase64(base64: string): Uint8Array {
|
||||||
|
if (
|
||||||
|
typeof base64 !== "string" ||
|
||||||
|
base64.length % 4 !== 0 ||
|
||||||
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid public key encoding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = decodeBase64(base64);
|
||||||
|
if (encodeBase64(bytes) !== base64) {
|
||||||
|
throw new Error("Invalid public key encoding");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
function toUint8(data: string | ArrayBuffer): Uint8Array {
|
function toUint8(data: string | ArrayBuffer): Uint8Array {
|
||||||
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
||||||
}
|
}
|
||||||
@@ -92,7 +109,7 @@ export function exportPublicKey(publicKey: Uint8Array): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importPublicKey(base64: string): Uint8Array {
|
export function importPublicKey(base64: string): Uint8Array {
|
||||||
const bytes = decodeBase64(base64);
|
const bytes = decodePublicKeyBase64(base64);
|
||||||
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
||||||
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
|
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
|
||||||
}
|
}
|
||||||
@@ -121,6 +138,14 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar
|
|||||||
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
|
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
|
||||||
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
|
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey);
|
||||||
|
const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT);
|
||||||
|
rawSharedResult.fill(0);
|
||||||
|
if (isAllZero) {
|
||||||
|
throw new Error("Invalid peer public key");
|
||||||
|
}
|
||||||
|
|
||||||
return nacl.box.before(peerPublicKey, ourSecretKey);
|
return nacl.box.before(peerPublicKey, ourSecretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.5.0-beta.4",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.5.0-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { expect, it, test, vi } from "vitest";
|
import { expect, it, test, vi } from "vitest";
|
||||||
import pino, { type Logger } from "pino";
|
import pino, { type Logger } from "pino";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentManager } from "./agent-manager.js";
|
import { AgentManager } from "./agent-manager.js";
|
||||||
@@ -8,8 +12,15 @@ import {
|
|||||||
formatSystemNotificationPrompt,
|
formatSystemNotificationPrompt,
|
||||||
isSystemInjectedEnvelope,
|
isSystemInjectedEnvelope,
|
||||||
setupFinishNotification,
|
setupFinishNotification,
|
||||||
|
waitForAgentRunStartWithTimeout,
|
||||||
} from "./agent-prompt.js";
|
} from "./agent-prompt.js";
|
||||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||||
|
import type {
|
||||||
|
AgentClient,
|
||||||
|
AgentRunResult,
|
||||||
|
AgentSession,
|
||||||
|
AgentStreamEvent,
|
||||||
|
} from "./agent-sdk-types.js";
|
||||||
|
|
||||||
interface CapturedLogger {
|
interface CapturedLogger {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -520,3 +531,234 @@ it("does not notify archived callers", async () => {
|
|||||||
expect(streamAgentSpy).not.toHaveBeenCalled();
|
expect(streamAgentSpy).not.toHaveBeenCalled();
|
||||||
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deliberately independent literals rather than the production constants these tests
|
||||||
|
// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests
|
||||||
|
// green if that constant were shortened back under a provider's startup budget.
|
||||||
|
const EXPECTED_RUN_START_BUDGET_MS = 60_000;
|
||||||
|
// The slowest provider startup budget the run-start wait has to sit outside of today
|
||||||
|
// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS).
|
||||||
|
const SLOWEST_PROVIDER_STARTUP_BUDGET_MS = 30_000;
|
||||||
|
|
||||||
|
const RUN_START_TEST_CAPABILITIES = {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsSessionListing: true,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider session whose turn start is held open for a configurable span, so the real
|
||||||
|
* AgentManager run-state transition (pendingRun.started -> lifecycle "running" ->
|
||||||
|
* agent_state) is what the run-start wait observes. `startDelayMs: null` never starts.
|
||||||
|
*/
|
||||||
|
class SlowStartAgentSession implements AgentSession {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = RUN_START_TEST_CAPABILITIES;
|
||||||
|
readonly id = randomUUID();
|
||||||
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
|
private releaseStartTurn!: () => void;
|
||||||
|
private readonly released = new Promise<void>((resolve) => {
|
||||||
|
this.releaseStartTurn = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private readonly startDelayMs: number | null) {}
|
||||||
|
|
||||||
|
async run(): Promise<AgentRunResult> {
|
||||||
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teardown hook so a never-starting turn cannot wedge the suite. */
|
||||||
|
release(): void {
|
||||||
|
this.releaseStartTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTurn(): Promise<{ turnId: string }> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (this.startDelayMs !== null) {
|
||||||
|
setTimeout(resolve, this.startDelayMs);
|
||||||
|
}
|
||||||
|
void this.released.then(resolve);
|
||||||
|
});
|
||||||
|
const turnId = "turn-1";
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||||
|
}, 0);
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pushEvent(event: AgentStreamEvent): void {
|
||||||
|
for (const callback of this.subscribers) {
|
||||||
|
callback(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||||
|
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentMode() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMode(): Promise<void> {}
|
||||||
|
|
||||||
|
getPendingPermissions() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondToPermission(): Promise<void> {}
|
||||||
|
|
||||||
|
describePersistence() {
|
||||||
|
return { provider: this.provider, sessionId: this.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlowStartAgentClient implements AgentClient {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = RUN_START_TEST_CAPABILITIES;
|
||||||
|
readonly sessions: SlowStartAgentSession[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly startDelayMs: number | null) {}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(): Promise<AgentSession> {
|
||||||
|
const session = new SlowStartAgentSession(this.startDelayMs);
|
||||||
|
this.sessions.push(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCatalog() {
|
||||||
|
return { models: [], modes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(): Promise<AgentSession> {
|
||||||
|
return await this.createSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real AgentManager driving a real agent, so the run-start wait exercises the production
|
||||||
|
* run-state and agent_state subscription path rather than a replaced method.
|
||||||
|
*/
|
||||||
|
async function createRunStartScenario(startDelayMs: number | null): Promise<{
|
||||||
|
agentManager: AgentManager;
|
||||||
|
agentId: string;
|
||||||
|
startRun: () => Promise<void>;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-run-start-budget-"));
|
||||||
|
const client = new SlowStartAgentClient(startDelayMs);
|
||||||
|
const agentManager = new AgentManager({
|
||||||
|
clients: { codex: client },
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
const snapshot = await agentManager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
let drained: Promise<void> = Promise.resolve();
|
||||||
|
return {
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
// streamAgent registers the pending run synchronously, so the wait always observes it.
|
||||||
|
startRun: async () => {
|
||||||
|
const run = agentManager.streamAgent(snapshot.id, "start the run");
|
||||||
|
drained = (async () => {
|
||||||
|
for await (const _event of run) {
|
||||||
|
// Drain whatever the turn produces.
|
||||||
|
}
|
||||||
|
})().catch(() => undefined);
|
||||||
|
},
|
||||||
|
cleanup: async () => {
|
||||||
|
// Release any turn still held open, then close. The drain is deliberately not
|
||||||
|
// awaited: depending on how far the turn got, the stream ends either from the
|
||||||
|
// release or from the close, and teardown must not depend on which.
|
||||||
|
for (const session of client.sessions) {
|
||||||
|
session.release();
|
||||||
|
}
|
||||||
|
await agentManager.closeAgent(snapshot.id).catch(() => undefined);
|
||||||
|
void drained;
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("waiting for a run start outlasts the slowest provider startup budget", async () => {
|
||||||
|
// A provider is still allowed to be starting here, so the outer wait must not abort it.
|
||||||
|
const scenario = await createRunStartScenario(SLOWEST_PROVIDER_STARTUP_BUDGET_MS + 5_000);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scenario.startRun();
|
||||||
|
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
|
||||||
|
let settled = false;
|
||||||
|
const markSettled = () => {
|
||||||
|
settled = true;
|
||||||
|
};
|
||||||
|
void wait.then(markSettled, markSettled);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(SLOWEST_PROVIDER_STARTUP_BUDGET_MS);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).not.toBe("running");
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await expect(wait).resolves.toBeUndefined();
|
||||||
|
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).toBe("running");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await scenario.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waiting for a run start still gives up at the run start budget", async () => {
|
||||||
|
const scenario = await createRunStartScenario(null);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scenario.startRun();
|
||||||
|
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
|
||||||
|
const rejection = expect(wait).rejects.toThrow(
|
||||||
|
"codex run did not start within 60 seconds (phase: run start)",
|
||||||
|
);
|
||||||
|
let settled = false;
|
||||||
|
const markSettled = () => {
|
||||||
|
settled = true;
|
||||||
|
};
|
||||||
|
void wait.then(markSettled, markSettled);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_RUN_START_BUDGET_MS - 1);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await rejection;
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await scenario.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -212,14 +212,35 @@ export interface StartCreatedAgentInitialPromptParams {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_RUN_START_TIMEOUT_MS = 15_000;
|
/**
|
||||||
|
* Outer bound on a run reaching "started" after dispatch.
|
||||||
|
*
|
||||||
|
* This wraps provider startup, so it MUST stay larger than the slowest provider's own
|
||||||
|
* startup budget — otherwise it aborts a start the provider was still allowed to be
|
||||||
|
* working on, and the provider's budget can never apply. OpenCode is the slowest today:
|
||||||
|
* up to 30s for the server to boot (OPENCODE_SERVER_STARTUP_TIMEOUT_MS) and then a
|
||||||
|
* session.create on the same budget, so this is deliberately set well above 30s.
|
||||||
|
*
|
||||||
|
* Not derived from the provider constant on purpose: this module is provider-agnostic
|
||||||
|
* and must not depend on a specific provider's internals.
|
||||||
|
*/
|
||||||
|
const AGENT_RUN_START_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export async function waitForAgentRunStartWithTimeout(
|
export async function waitForAgentRunStartWithTimeout(
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
|
||||||
const startAbort = new AbortController();
|
const startAbort = new AbortController();
|
||||||
const startTimeout = setTimeout(() => startAbort.abort("timeout"), AGENT_RUN_START_TIMEOUT_MS);
|
const startTimeout = setTimeout(
|
||||||
|
() =>
|
||||||
|
startAbort.abort(
|
||||||
|
new Error(
|
||||||
|
`${provider} run did not start within ${AGENT_RUN_START_TIMEOUT_MS / 1000} seconds (phase: run start)`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AGENT_RUN_START_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
||||||
|
|||||||
@@ -343,6 +343,81 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test("creates a session when session.create needs more than ten seconds", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
const openCode = new TestOpenCodeClient();
|
||||||
|
const slowCreate = createTestDeferred<void>();
|
||||||
|
openCode.sessionCreateImplementation = async () => {
|
||||||
|
await slowCreate.promise;
|
||||||
|
return { data: { id: "session-1" } };
|
||||||
|
};
|
||||||
|
runtime.enqueueClient(openCode);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creation = client.createSession(buildConfig(cwd));
|
||||||
|
let settled = false;
|
||||||
|
const markSettled = () => {
|
||||||
|
settled = true;
|
||||||
|
};
|
||||||
|
void creation.then(markSettled, markSettled);
|
||||||
|
|
||||||
|
// Under CPU contention a per-directory bootstrap has been measured well past ten
|
||||||
|
// seconds, which the previous budget failed outright.
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
slowCreate.resolve();
|
||||||
|
const session = await creation;
|
||||||
|
expect(session.provider).toBe("opencode");
|
||||||
|
expect(openCode.calls.sessionCreate).toHaveLength(1);
|
||||||
|
await session.close();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
slowCreate.resolve();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounds session.create at the server startup budget", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
const openCode = new TestOpenCodeClient();
|
||||||
|
openCode.sessionCreateImplementation = () => new Promise<never>(() => undefined);
|
||||||
|
runtime.enqueueClient(openCode);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creation = client.createSession(buildConfig(cwd));
|
||||||
|
const rejection = expect(creation).rejects.toThrow("OpenCode session.create timed out");
|
||||||
|
let settled = false;
|
||||||
|
const markSettled = () => {
|
||||||
|
settled = true;
|
||||||
|
};
|
||||||
|
void creation.then(markSettled, markSettled);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_STARTUP_BUDGET_MS - 1);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await rejection;
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
expect(runtime.acquisitions.at(-1)?.releaseCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("archives and unarchives the durable native session through client hooks", async () => {
|
test("archives and unarchives the durable native session through client hooks", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const runtime = new TestOpenCodeHarness();
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
|||||||
@@ -1396,10 +1396,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Creating the first session for a directory is part of OpenCode coming up, so it
|
||||||
|
// shares the server startup budget instead of a shorter one that fails agent
|
||||||
|
// creation on contended cold starts.
|
||||||
const response = await withTimeout(
|
const response = await withTimeout(
|
||||||
client.session.create({ directory: openCodeConfig.cwd }),
|
client.session.create({ directory: openCodeConfig.cwd }),
|
||||||
10_000,
|
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
|
||||||
"OpenCode session.create timed out after 10s",
|
`OpenCode session.create timed out after ${Math.round(
|
||||||
|
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
|
||||||
|
)}s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
|||||||
+4
@@ -145,6 +145,7 @@ export class TestOpenCodeClient {
|
|||||||
sessionCommandEvents: unknown[] = [idleEvent()];
|
sessionCommandEvents: unknown[] = [idleEvent()];
|
||||||
sessionCommandResponse: OpenCodeResponse = {};
|
sessionCommandResponse: OpenCodeResponse = {};
|
||||||
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
||||||
|
sessionCreateImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
|
||||||
sessionDeleteResponse: OpenCodeResponse = {};
|
sessionDeleteResponse: OpenCodeResponse = {};
|
||||||
sessionChildrenResponses: OpenCodeResponse[] = [];
|
sessionChildrenResponses: OpenCodeResponse[] = [];
|
||||||
sessionChildrenImplementation:
|
sessionChildrenImplementation:
|
||||||
@@ -294,6 +295,9 @@ export class TestOpenCodeClient {
|
|||||||
},
|
},
|
||||||
create: async (parameters: unknown) => {
|
create: async (parameters: unknown) => {
|
||||||
this.calls.sessionCreate.push(parameters);
|
this.calls.sessionCreate.push(parameters);
|
||||||
|
if (this.sessionCreateImplementation) {
|
||||||
|
return await this.sessionCreateImplementation(parameters);
|
||||||
|
}
|
||||||
return this.sessionCreateResponse;
|
return this.sessionCreateResponse;
|
||||||
},
|
},
|
||||||
delete: async (parameters: unknown) => {
|
delete: async (parameters: unknown) => {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer {
|
|||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed transport key for an unsupported peer public key.
|
||||||
|
const UNSUPPORTED_PEER_TRANSPORT_KEY = Uint8Array.from(
|
||||||
|
Buffer.from("351f86faa3b988468a850122b65b0acece9c4826806aeee63de9c0da2bd7f91e", "hex"),
|
||||||
|
);
|
||||||
|
|
||||||
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
|
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
|
||||||
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
|
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
|
||||||
return JSON.parse(plaintext);
|
return JSON.parse(plaintext);
|
||||||
@@ -161,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`);
|
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForCapturedLog(
|
||||||
|
lines: string[],
|
||||||
|
predicate: (line: string) => boolean,
|
||||||
|
timeout = 1000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (lines.some(predicate)) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for captured log. Recent logs:\n${lines.slice(-20).join("")}`);
|
||||||
|
}
|
||||||
|
|
||||||
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
|
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
|
||||||
let relayPort: number;
|
let relayPort: number;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
let relayStdoutLines: string[] = [];
|
let relayStdoutLines: string[] = [];
|
||||||
|
|
||||||
const startRelay = async () => {
|
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
|
||||||
relayStdoutLines = [];
|
relayStdoutLines = [];
|
||||||
relayPort = await getAvailablePort();
|
relayPort = await getAvailablePort();
|
||||||
const relayDir = path.resolve(process.cwd(), "../relay");
|
const relayDir = path.resolve(process.cwd(), "../relay");
|
||||||
relayProcess = spawn(
|
const relayArgs = [
|
||||||
"npx",
|
|
||||||
[
|
|
||||||
"wrangler",
|
"wrangler",
|
||||||
"dev",
|
"dev",
|
||||||
"--local",
|
"--local",
|
||||||
@@ -182,14 +198,16 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
String(relayPort),
|
String(relayPort),
|
||||||
"--live-reload=false",
|
"--live-reload=false",
|
||||||
"--show-interactive-dev-session=false",
|
"--show-interactive-dev-session=false",
|
||||||
],
|
];
|
||||||
{
|
if (options.useLocalRelay) {
|
||||||
|
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
|
||||||
|
}
|
||||||
|
relayProcess = spawn("npx", relayArgs, {
|
||||||
cwd: relayDir,
|
cwd: relayDir,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data
|
const lines = data
|
||||||
@@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
}
|
}
|
||||||
}, 90000);
|
}, 90000);
|
||||||
|
|
||||||
|
test("daemon closes a relay client that sends an unsupported handshake key", async () => {
|
||||||
|
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||||
|
|
||||||
|
const { logger, lines } = createCapturingLogger();
|
||||||
|
await startRelay({ useLocalRelay: true });
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
listen: "127.0.0.1",
|
||||||
|
logger,
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offerUrl = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon.paseoHome,
|
||||||
|
relayEnabled: daemon.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon.config.appBaseUrl,
|
||||||
|
});
|
||||||
|
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
|
||||||
|
const ws = new WebSocket(
|
||||||
|
buildRelayWebSocketUrl({
|
||||||
|
endpoint: `127.0.0.1:${relayPort}`,
|
||||||
|
useTls: false,
|
||||||
|
serverId,
|
||||||
|
role: "client",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let receivedServerInfo = false;
|
||||||
|
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
|
||||||
|
const outcome = await new Promise<
|
||||||
|
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
|
||||||
|
>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
ws.on("open", async () => {
|
||||||
|
try {
|
||||||
|
await waitForCapturedLog(lines, isRelayDataConnectedLog);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "e2ee_hello",
|
||||||
|
key: exportPublicKey(new Uint8Array(32)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
encodeCiphertext(
|
||||||
|
encrypt(
|
||||||
|
UNSUPPORTED_PEER_TRANSPORT_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "cid_relay_unsupported_key",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
try {
|
||||||
|
const parsed = WSOutboundMessageSchema.parse(
|
||||||
|
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
parsed.type === "session" &&
|
||||||
|
parsed.message.type === "status" &&
|
||||||
|
parsed.message.payload?.status === "server_info"
|
||||||
|
) {
|
||||||
|
receivedServerInfo = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(
|
||||||
|
new Error("daemon sent server_info after accepting an unsupported handshake key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on("close", (code, reason) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ type: "closed", code, reason: reason.toString() });
|
||||||
|
});
|
||||||
|
ws.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ type: "errored", error });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedServerInfo).toBe(false);
|
||||||
|
if (outcome.type === "closed") {
|
||||||
|
expect(outcome.code).toBeGreaterThan(0);
|
||||||
|
} else {
|
||||||
|
expect(outcome.error).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRejectedHandshakeLog = (line: string) =>
|
||||||
|
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
|
||||||
|
await waitForCapturedLog(lines, isRejectedHandshakeLog);
|
||||||
|
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await daemon.close();
|
||||||
|
await stopRelay();
|
||||||
|
}
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
|
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
|
||||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects an over-budget file before opening a binary transfer", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-read-budget-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "hello world");
|
||||||
|
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
|
||||||
|
|
||||||
|
await subsystem.handleFileExplorerRequest({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-read-budget",
|
||||||
|
acceptBinary: true,
|
||||||
|
maxBytes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(binary).toEqual([]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: expect.objectContaining({ error: "File is too large to display" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
|
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
|
||||||
const cwd = makeDir("workspace-files-large-binary-");
|
const cwd = makeDir("workspace-files-large-binary-");
|
||||||
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
|
|||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (request.maxBytes) {
|
||||||
|
const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath });
|
||||||
|
if (file.size > request.maxBytes) {
|
||||||
|
throw new Error("File is too large to display");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
||||||
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
||||||
await this.host.emitBinary(
|
await this.host.emitBinary(
|
||||||
|
|||||||
+8
-1
@@ -263,6 +263,7 @@ Use `PASEO_HOME` to run multiple isolated daemon instances.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo hub login [url] # Approve and store organization-scoped CLI access
|
paseo hub login [url] # Approve and store organization-scoped CLI access
|
||||||
|
paseo hub init # Guided setup: scaffold and deploy a starter bundle here
|
||||||
paseo hub connect [url] # Enroll this daemon using CLI access
|
paseo hub connect [url] # Enroll this daemon using CLI access
|
||||||
paseo hub projects # List projects in the authenticated organization
|
paseo hub projects # List projects in the authenticated organization
|
||||||
paseo hub status # Show the current Hub relationship
|
paseo hub status # Show the current Hub relationship
|
||||||
@@ -276,7 +277,13 @@ Run deploy from the project root. It reads `.paseo/hub.yml`, every direct `.pase
|
|||||||
|
|
||||||
Pass `-p, --project <slug>` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count.
|
Pass `-p, --project <slug>` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count.
|
||||||
|
|
||||||
`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. The stored login is separate from the daemon relationship created by `connect`. Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved.
|
`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it then asks whether to connect this daemon and whether to initialize and deploy a starter workflow, both defaulting to yes. Declining the connection prints `paseo hub connect <origin>; then paseo hub init`, because the connection alone does not produce a bundle; declining only the starter prints `paseo hub init`. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`.
|
||||||
|
|
||||||
|
`init` runs the same guided setup on its own and requires a TTY. It connects the daemon, uses the organization's only project or asks which one, and lists the Hub app connections that can back a starter workflow. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
|
||||||
|
|
||||||
|
Setup then asks which agent provider, model, and mode the starter should run, choosing from what the connected daemon reports. A provider is offered only when the daemon has it enabled with a selectable model. Suggested model and mode entries are the daemon's defaults; no provider is suggested merely because it appears first. The mode question is skipped for providers that expose no modes and asked explicitly when the daemon has modes but no default. Finally, setup asks for the identity that gates the chosen connection: a GitHub username, a Slack member ID, or a Discord user ID. It writes `.paseo/hub.yml` and `.paseo/workflows/<provider>-help.yml`, validates them against Hub, and deploys. An existing `.paseo/` directory is replaced only after you confirm. See the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle).
|
||||||
|
|
||||||
|
Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved.
|
||||||
|
|
||||||
Every command resolves and normalizes its destination before Hub or daemon work. Origin precedence is an explicit command origin or `--hub`, then `PASEO_HUB_URL`, then the active stored login origin, then the hosted default `https://hub.paseo.sh`. The hosted default never overrides an active login. Credential precedence is `--api-key <secret>`, then `PASEO_HUB_API_KEY`, then a stored login for the exact resolved origin. A stored credential is never sent to a different origin. API keys passed through flags or the environment are not stored.
|
Every command resolves and normalizes its destination before Hub or daemon work. Origin precedence is an explicit command origin or `--hub`, then `PASEO_HUB_URL`, then the active stored login origin, then the hosted default `https://hub.paseo.sh`. The hosted default never overrides an active login. Credential precedence is `--api-key <secret>`, then `PASEO_HUB_API_KEY`, then a stored login for the exact resolved origin. A stored credential is never sent to a different origin. API keys passed through flags or the environment are not stored.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Work down this list.
|
|||||||
|
|
||||||
1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures.
|
1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures.
|
||||||
2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type.
|
2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type.
|
||||||
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name.
|
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. See [find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) and [find your Discord IDs](/docs/hub/triggers/discord#find-your-discord-ids).
|
||||||
4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention.
|
4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention.
|
||||||
5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving.
|
5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving.
|
||||||
6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`.
|
6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`.
|
||||||
|
|||||||
@@ -23,6 +23,60 @@ A project configuration is one versioned bundle:
|
|||||||
|
|
||||||
[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists.
|
[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists.
|
||||||
|
|
||||||
|
## Generated starter bundle
|
||||||
|
|
||||||
|
`paseo hub init`, and the guided setup that interactive `paseo hub login` offers, write two files for the directory you run them in:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .paseo/hub.yml
|
||||||
|
environments:
|
||||||
|
my-macbook:
|
||||||
|
kind: daemon
|
||||||
|
daemon: my-macbook
|
||||||
|
cwd: /Users/you/code/your-repo
|
||||||
|
agents:
|
||||||
|
starter:
|
||||||
|
provider: codex
|
||||||
|
model: gpt-5
|
||||||
|
mode: full-access
|
||||||
|
```
|
||||||
|
|
||||||
|
The environment is named after the connected daemon and points at the directory setup ran in. `provider`, `model`, and `mode` are the runtime you chose from what that daemon reported, so the starter agent is a complete selection before its first run. `mode` is omitted for a provider that exposes none.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .paseo/workflows/slack-help.yml
|
||||||
|
name: slack-help
|
||||||
|
on: slack.mention
|
||||||
|
max_runtime: 2h
|
||||||
|
filters:
|
||||||
|
workspace: T01234567
|
||||||
|
from_users:
|
||||||
|
- U01234567
|
||||||
|
steps:
|
||||||
|
- id: work
|
||||||
|
environment: my-macbook
|
||||||
|
max_runtime: 90m
|
||||||
|
idle_timeout: 10m
|
||||||
|
agent: starter
|
||||||
|
prompt:
|
||||||
|
- text: |
|
||||||
|
Answer with hub.reply, then complete this request and call hub.finish_execution when done.
|
||||||
|
|
||||||
|
<user-prompt>
|
||||||
|
${{ paseo.prompt }}
|
||||||
|
</user-prompt>
|
||||||
|
allow_outputs:
|
||||||
|
- type: slack.reply
|
||||||
|
max: 1
|
||||||
|
required: true
|
||||||
|
```
|
||||||
|
|
||||||
|
`filters` carries the identity each provider matches on: the Slack team and member ID above, the Discord guild and user ID for a Discord starter, and `owner/name` plus your login for GitHub.
|
||||||
|
|
||||||
|
A Discord starter is `discord-help.yml` and carries `discord.reply`, the counterpart of the `slack.reply` above. A GitHub starter is `github-help.yml` and declares no reply output; GitHub has no reply capability, so a step that must comment needs a [`github` block](/docs/hub/github) instead.
|
||||||
|
|
||||||
|
The generated workflow allows one user in one workspace. Read [Hub security](/docs/hub/security) before widening it.
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
A configuration comes from one source:
|
A configuration comes from one source:
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ Log in from the machine first:
|
|||||||
paseo hub login https://hub.example.com
|
paseo hub login https://hub.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. Then enroll the daemon:
|
The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`.
|
||||||
|
|
||||||
|
In an interactive terminal, login then offers to finish setup: whether to connect this daemon, and whether to initialize and deploy a starter workflow. Both default to yes. Declining the connection prints `paseo hub connect <origin>; then paseo hub init`, since connecting alone leaves the project without a workflow. Declining only the starter prints `paseo hub init`. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through the questions.
|
||||||
|
|
||||||
|
Enroll the daemon on its own when you declined, or when the machine is already logged in:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paseo hub connect
|
paseo hub connect
|
||||||
|
|||||||
@@ -28,18 +28,16 @@ Your daemons keep running agents where they always did. Hub decides when to ask
|
|||||||
|
|
||||||
## What lives in your repository
|
## What lives in your repository
|
||||||
|
|
||||||
`paseo hub init` creates a project resource file for environments and agents, plus a safe starter workflow:
|
Guided setup creates a project resource file for environments and agents, plus one safe starter workflow:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.paseo/
|
.paseo/
|
||||||
├── hub.yml
|
├── hub.yml
|
||||||
└── workflows/
|
└── workflows/
|
||||||
├── slack-help.yml
|
└── slack-help.yml
|
||||||
└── partials/
|
|
||||||
└── answer.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Deploy the bundle, mention the bot, and an agent starts on your machine. [Quickstart](/docs/hub/quickstart) runs the guided setup; [Workflows](/docs/hub/workflows) covers routing and provider-specific replies.
|
Guided setup deploys the bundle, and mentioning the bot starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle) shows what it wrote, and [Workflows](/docs/hub/workflows) covers routing, prompt partials, and provider-specific replies.
|
||||||
|
|
||||||
## Reading order
|
## Reading order
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ category: Hub
|
|||||||
|
|
||||||
# Hub quickstart
|
# Hub quickstart
|
||||||
|
|
||||||
Run Hub locally, connect it to Slack without a public server, and answer a mention with an agent on your machine.
|
Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and guided setup writes and deploys the workflow for you.
|
||||||
|
|
||||||
You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app.
|
You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app.
|
||||||
|
|
||||||
@@ -18,13 +18,13 @@ You need [Paseo installed and running](/docs), Node.js, and a Slack workspace wh
|
|||||||
npx @getpaseo/hub
|
npx @getpaseo/hub
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:3000>. Hub uses an embedded database by default, so this first run needs no database, environment variables, or Docker.
|
Open the address it prints, normally <http://localhost:3000>, and create the operator account Hub asks for.
|
||||||
|
|
||||||
Create the operator account when Hub welcomes you.
|
The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database, your organization, and a **Default** project. You never create a project by hand.
|
||||||
|
|
||||||
## 2. Connect Slack
|
## 2. Connect Slack
|
||||||
|
|
||||||
The next screen explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and does not need a public address or HTTPS.
|
**Set up your apps** explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and needs no public address or HTTPS.
|
||||||
|
|
||||||
Paste the App-level token and Bot token back into Hub, then choose **Connect Slack**. Invite the bot to the channel where you will use it:
|
Paste the App-level token and Bot token back into Hub, then choose **Connect Slack**. Invite the bot to the channel where you will use it:
|
||||||
|
|
||||||
@@ -32,35 +32,63 @@ Paste the App-level token and Bot token back into Hub, then choose **Connect Sla
|
|||||||
/invite @Paseo
|
/invite @Paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
You can skip GitHub and Discord for now. Their setup remains available under **Apps**.
|
GitHub and Discord can wait. Their setup stays available under **Apps**.
|
||||||
|
|
||||||
## 3. Initialize the project
|
## 3. Connect the machine your code is on
|
||||||
|
|
||||||
From the repository where the agent should work:
|
**Connect a daemon** shows one command with this Hub's address already in it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paseo hub init
|
paseo hub login http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose **Custom endpoint…** and enter `http://localhost:3000`, then choose Slack. The guided setup:
|
Run it on the machine where your code lives, in the repository the agent should work in. Guided setup records that directory as the workflow's working directory.
|
||||||
|
|
||||||
- signs you in through the browser;
|
Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. **Continue** and **Do this later** both land in the Default project.
|
||||||
- connects the local Paseo daemon;
|
|
||||||
- uses the default project created during first-run setup;
|
|
||||||
- selects the connected Slack workspace and asks for your Slack username;
|
|
||||||
- writes `.paseo/hub.yml` and `.paseo/workflows/slack-help.yml`;
|
|
||||||
- validates the generated bundle and offers to deploy it.
|
|
||||||
|
|
||||||
Accept the default **Deploy now?** choice. The generated workflow accepts mentions only from the username you entered.
|
## 4. Answer the setup questions
|
||||||
|
|
||||||
## 4. Mention the bot
|
Your terminal confirms the login, then picks up where the browser left off. Most questions arrive with a default or a suggested answer; only your Slack member ID has to be typed.
|
||||||
|
|
||||||
Mention the bot in the channel:
|
| Question | What it wants |
|
||||||
|
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| Connect this daemon to this Hub? | Yes. This enrolls the machine you are on. |
|
||||||
|
| Initialize and deploy a starter workflow? | Yes. |
|
||||||
|
| Starter agent provider, model, and mode | What your daemon reports it can run. Suggested model and mode entries are its defaults. |
|
||||||
|
| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. |
|
||||||
|
|
||||||
|
Setup lists the app connections ready for this workflow. Because you connected one Slack workspace in step 2, it selects that connection automatically instead of asking you to choose Slack. If several usable connections exist, setup asks for the **Trigger connection**. If none is ready, it sends you to **Hub → Apps** and stops before asking about the agent or writing files.
|
||||||
|
|
||||||
|
The agent provider list contains only runtimes the daemon can use; it does not suggest one arbitrarily. Suggested model and mode entries are defaults reported by the daemon. A provider that has modes but no default mode is still offered; setup asks you to pick the mode instead of guessing one.
|
||||||
|
|
||||||
|
[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) has the two clicks that copy your member ID. The Slack workspace comes from the app you connected in step 2, so setup does not ask for it.
|
||||||
|
|
||||||
|
Setup then validates the bundle, writes it, and deploys:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@Paseo explain what this project does
|
.paseo/
|
||||||
|
├── hub.yml
|
||||||
|
└── workflows/
|
||||||
|
└── slack-help.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Hub starts the agent on your daemon and posts its reply in the Slack thread. Open the project's **Activity** tab if nothing runs.
|
If `.paseo/` already exists, setup asks before replacing it. Declining the daemon connection prints `paseo hub connect <hub>; then paseo hub init` — both commands, because connecting alone does not create the workflow. Declining only the starter workflow prints `paseo hub init`.
|
||||||
|
|
||||||
Hub keeps its local state in your user data directory (normally `~/.local/share/paseo-hub`). [Self-hosting](/docs/hub/self-hosting) covers PostgreSQL, public URLs, environment-managed apps, Docker, and cloud deployment. [Configuration](/docs/hub/configuration) explains the generated files and manual deployment.
|
## 5. Mention the bot
|
||||||
|
|
||||||
|
In the channel you invited the bot to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@Paseo have a look
|
||||||
|
```
|
||||||
|
|
||||||
|
Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal prints the project's Activity URL, where the run appears. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a workflow.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [How Hub works](/docs/hub/concepts) — how an event becomes a workflow run on your daemon.
|
||||||
|
- [Generated starter bundle](/docs/hub/configuration#generated-starter-bundle) — the two files setup wrote, field by field.
|
||||||
|
- [Workflows](/docs/hub/workflows) — routing, prompts, and provider replies.
|
||||||
|
- [Hub security](/docs/hub/security) — read this before widening `from_users` or giving an agent GitHub authority.
|
||||||
|
|
||||||
|
Hub keeps its local state in your user data directory, normally `~/.local/share/paseo-hub`. [Self-hosting](/docs/hub/self-hosting) covers deployment and advanced configuration when you outgrow the local run.
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ steps:
|
|||||||
- { type: discord.reply, max: 1, required: true }
|
- { type: discord.reply, max: 1, required: true }
|
||||||
```
|
```
|
||||||
|
|
||||||
Turn on Discord Developer Mode to copy IDs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
|
Discord filters use IDs, not server names, display names, or Hub connection slugs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
|
||||||
|
|
||||||
The reply posts in the triggering thread or channel. `discord.reply` grants `hub.reply`, but does not rewrite the prompt. A Discord trigger grants no GitHub credential; add a [`github` block](/docs/hub/github) to the step that needs one.
|
The reply posts in the triggering thread or channel. `discord.reply` grants `hub.reply`, but does not rewrite the prompt. A Discord trigger grants no GitHub credential; add a [`github` block](/docs/hub/github) to the step that needs one.
|
||||||
|
|
||||||
Leading declared inputs follow the mention. Hub exposes the remaining text as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
|
Leading declared inputs follow the mention. Hub exposes the remaining text as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
|
||||||
|
|
||||||
|
## Find your Discord IDs
|
||||||
|
|
||||||
|
Turn on **Settings → Advanced → Developer Mode**, then right-click to **Copy Server ID** for `guild`, **Copy Channel ID** for `channels`, and **Copy User ID** for `from_users`. All three are numeric snowflakes; quote them so YAML keeps them as strings.
|
||||||
|
|
||||||
|
[Guided setup](/docs/hub/quickstart) fills `guild` from the Discord app you connected and asks you for your user ID.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ steps:
|
|||||||
- { type: slack.reply, max: 1, required: true }
|
- { type: slack.reply, max: 1, required: true }
|
||||||
```
|
```
|
||||||
|
|
||||||
Slack filters use IDs, not display names. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
|
Slack filters use IDs, not display names or Hub connection slugs. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass.
|
||||||
|
|
||||||
The reply posts in the triggering thread. A root message gets a thread; a threaded message stays there. `slack.reply` grants `hub.reply`, but does not add reply instructions to the prompt.
|
The reply posts in the triggering thread. A root message gets a thread; a threaded message stays there. `slack.reply` grants `hub.reply`, but does not add reply instructions to the prompt.
|
||||||
|
|
||||||
@@ -45,3 +45,13 @@ Leading declared inputs follow the mention:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Hub consumes consecutive declared headers and exposes the remainder as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
|
Hub consumes consecutive declared headers and exposes the remainder as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows).
|
||||||
|
|
||||||
|
## Find your Slack IDs
|
||||||
|
|
||||||
|
| Filter | Where to copy it |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `workspace` | Open Slack in a browser. The team ID is the `T…` segment of the URL. |
|
||||||
|
| `from_users` | Your avatar → **Profile** → **⋮** → **Copy member ID**. Member IDs start with `U`. |
|
||||||
|
| `channels` | Channel name → **About**. The channel ID is at the bottom of the panel and starts with `C`. |
|
||||||
|
|
||||||
|
[Guided setup](/docs/hub/quickstart) fills `workspace` from the Slack app you connected and asks you for your member ID, so a generated starter already carries both.
|
||||||
|
|||||||
Reference in New Issue
Block a user