mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 11:55:03 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc78b951b | ||
|
|
e2f31e5db2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b | ||
|
|
8b3b2a9ea3 | ||
|
|
b6952995be | ||
|
|
c558af91eb | ||
|
|
7791dcee3b | ||
|
|
e532a24327 | ||
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe |
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
|
||||
from `packages/app/android`:
|
||||
|
||||
```bash
|
||||
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
@@ -85,6 +92,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
Terminal arrows never pass through the shortcut pipeline. The Android hardware
|
||||
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
|
||||
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
|
||||
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
|
||||
+10
-1
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
+2
-1
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
|
||||
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
|
||||
|
||||
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
});
|
||||
await test.step("recall a slash message without opening its picker, then edit it", async () => {
|
||||
await expectComposerDraft(page, "");
|
||||
const reply = await session.client.waitForFinish(session.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 4);
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -127,8 +127,42 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -159,7 +193,9 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("created-agent-prompt-error.png"),
|
||||
});
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
test.describe("Updates to agents in other workspaces", () => {
|
||||
let open: SeededWorkspace;
|
||||
let other: SeededWorkspace;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await open?.cleanup();
|
||||
await other?.cleanup();
|
||||
});
|
||||
|
||||
test("do not re-render the open workspace screen", async ({ page }) => {
|
||||
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
|
||||
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
|
||||
const openAgentId = await createMockAgent(open, "Open agent");
|
||||
const otherAgentId = await createMockAgent(other, "Other agent");
|
||||
|
||||
await recordReactRenders(page);
|
||||
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await resetReactRenders(page);
|
||||
|
||||
await other.client.sendAgentMessage(otherAgentId, "update");
|
||||
await other.client.waitForFinish(otherAgentId, 60_000);
|
||||
// The favicon follows every agent on the host, so it renders when the other agent runs and
|
||||
// settles. That confirms the updates reached this page.
|
||||
await expect
|
||||
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -8,9 +8,12 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="android.app.Activity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Exercises the real Android window and editable that receive physical keys. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TerminalArrowTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync {
|
||||
activity?.let {
|
||||
HardwareKeyboard.release(it.window)
|
||||
it.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
|
||||
withInput(terminal = true) { input, host ->
|
||||
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
|
||||
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
|
||||
input.setSelection(3)
|
||||
for (repeat in 0..2) {
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
|
||||
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
|
||||
}
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
|
||||
}
|
||||
}
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abcx", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsStillEditOtherTextFields() {
|
||||
withInput(terminal = false) { input, host ->
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
assertEquals(2, input.selectionStart)
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abxc", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
|
||||
val host = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = host
|
||||
lateinit var input: EditText
|
||||
instrumentation.runOnMainSync {
|
||||
input = EditText(host)
|
||||
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
|
||||
host.setContentView(input)
|
||||
input.setText("abc")
|
||||
input.requestFocus()
|
||||
input.setSelection(3)
|
||||
HardwareKeyboard.observe(host.window)
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
instrumentation.runOnMainSync {
|
||||
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
|
||||
check(input, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -10,18 +10,24 @@ import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.R
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
// Matched against the hidden terminal input's `nativeID`
|
||||
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
|
||||
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
// Without this the hidden input consumes the arrows as caret movement and
|
||||
// its next text change stops being a plain append. Modifiers stay
|
||||
// forwarded: Alt/Shift+arrow are terminal editing keys.
|
||||
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
|
||||
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey || terminalOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val focus = window.currentFocus
|
||||
val editable = (focus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
|
||||
return HardwareKeyboard.dispatcher.dispatch(
|
||||
event,
|
||||
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
|
||||
{ previous.dispatchKeyEvent(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a quote selection ends partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
const quote = document.createElement("div");
|
||||
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
|
||||
quote.textContent = "Quoted context";
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "Quoted context"), 0],
|
||||
[tokenText(message, " (answer)"), 9],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps indented code lines inside their list item", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
|
||||
highlightedCodeBlock("typescript"),
|
||||
"</div></div></div></div>",
|
||||
].join("");
|
||||
document.body.append(host);
|
||||
|
||||
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a selection starts partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, " answer"), 1],
|
||||
[tokenText(message, "After the block."), "After the block.".length],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic library output", () => {
|
||||
|
||||
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
|
||||
const item = content
|
||||
.replace(/^\n+/, "")
|
||||
.replace(/\n+$/, "\n")
|
||||
.replace(/\n(?=\S)/g, "\n ");
|
||||
// Every non-empty line, so indented code lines stay inside the item.
|
||||
.replace(/\n(?=[^\n])/g, "\n ");
|
||||
const parent = node.parentElement;
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (isCrossBoundaryCodeElement(range, element, tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
|
||||
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
|
||||
return isCode && !selectionStaysInsideElement(range, element);
|
||||
}
|
||||
|
||||
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
return (
|
||||
range.startContainer !== element &&
|
||||
|
||||
@@ -63,10 +63,25 @@ const html = `<!doctype html>
|
||||
#diagram svg {
|
||||
display: block;
|
||||
}
|
||||
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
|
||||
at 0x0 or 240x240 before the diagram's own dimensions are known. */
|
||||
#staging-clip {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
#staging {
|
||||
width: 16384px;
|
||||
height: 16384px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="diagram"></div>
|
||||
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -40,6 +40,9 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
|
||||
],
|
||||
flowchart: { htmlLabels: false },
|
||||
class: { htmlLabels: false },
|
||||
// A gantt chart takes the width of the element it renders in, which is the oversized staging
|
||||
// container. Keep it as wide as this frame, which on native is the message column.
|
||||
gantt: { useWidth: document.body.clientWidth },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +100,15 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
|
||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
|
||||
const staging = document.getElementById("staging");
|
||||
if (!staging) {
|
||||
throw new Error("Missing Mermaid staging container");
|
||||
}
|
||||
const { svg } = await mermaid.render(
|
||||
`paseo-mermaid-${message.revision}`,
|
||||
message.source,
|
||||
staging,
|
||||
);
|
||||
if (message.revision !== latestRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+104
-1
@@ -29,9 +29,15 @@ function waitForRuntimeMessage(
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
async function mountRuntime(
|
||||
size?: { width: number; height: number },
|
||||
options?: { allowSameOrigin?: boolean },
|
||||
): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.sandbox.add("allow-scripts");
|
||||
if (options?.allowSameOrigin) {
|
||||
frame.sandbox.add("allow-same-origin");
|
||||
}
|
||||
if (size) {
|
||||
frame.style.width = `${size.width}px`;
|
||||
frame.style.height = `${size.height}px`;
|
||||
@@ -112,6 +118,103 @@ describe("Mermaid sandbox runtime", () => {
|
||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
||||
});
|
||||
|
||||
/**
|
||||
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
|
||||
* ResizeObserver settles), then resizes the frame to the reported diagram size without
|
||||
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
|
||||
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
|
||||
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
|
||||
* X-axis labels clipped at the bottom edge.
|
||||
*/
|
||||
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
|
||||
const source = [
|
||||
"xychart-beta",
|
||||
' title "Throughput by Stage (req/s)"',
|
||||
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
|
||||
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
|
||||
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
|
||||
].join("\n");
|
||||
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
|
||||
const measuringFrame = await mountRuntime(
|
||||
{ height: 240, width: 240 },
|
||||
{ allowSameOrigin: true },
|
||||
);
|
||||
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
|
||||
|
||||
await render(zeroFrame, { revision: 1, source });
|
||||
await render(measuringFrame, { revision: 1, source });
|
||||
await render(fullFrame, { revision: 1, source });
|
||||
|
||||
for (const frame of [zeroFrame, measuringFrame]) {
|
||||
frame.style.width = "700px";
|
||||
frame.style.height = "500px";
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) {
|
||||
throw new Error("Expected inspectable runtime document");
|
||||
}
|
||||
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
|
||||
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
|
||||
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
|
||||
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
|
||||
expect(svgRect).not.toBeUndefined();
|
||||
expect(yTitleRect).not.toBeUndefined();
|
||||
expect(yLabels.length).toBeGreaterThan(0);
|
||||
expect(xLabels.length).toBeGreaterThan(0);
|
||||
|
||||
for (const label of yLabels) {
|
||||
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
|
||||
}
|
||||
for (const label of xLabels) {
|
||||
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
|
||||
const doc = frame.contentDocument;
|
||||
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
|
||||
const bottomPath =
|
||||
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
|
||||
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
|
||||
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
|
||||
if (!leftMatch || !bottomMatch) {
|
||||
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
|
||||
}
|
||||
return {
|
||||
leftAxisX: Number.parseFloat(leftMatch[1]),
|
||||
bottomAxisY: Number.parseFloat(bottomMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const fullOrigin = axisOrigin(fullFrame);
|
||||
for (const candidate of [measuringFrame, zeroFrame]) {
|
||||
const origin = axisOrigin(candidate);
|
||||
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
|
||||
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
|
||||
* offscreen container must keep it as wide as the frame, which on native is the message column.
|
||||
*/
|
||||
it("renders a gantt chart as wide as the frame the host gives it", async () => {
|
||||
const source = [
|
||||
"gantt",
|
||||
" title Release plan",
|
||||
" dateFormat YYYY-MM-DD",
|
||||
" section Build",
|
||||
" Design :a1, 2024-01-01, 10d",
|
||||
" Implement :after a1, 20d",
|
||||
].join("\n");
|
||||
const widths = [];
|
||||
for (const width of [240, 350, 900]) {
|
||||
const frame = await mountRuntime({ height: 240, width });
|
||||
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
|
||||
}
|
||||
|
||||
expect(widths).toEqual([240, 350, 900]);
|
||||
});
|
||||
|
||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
|
||||
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
|
||||
* never open on mobile, so native renders the shared gesture-aware pressable
|
||||
* instead of a plain Pressable: inside the model list's independent scroll
|
||||
* viewport (Android, wide windows) the native scroll gesture cancels a plain
|
||||
* Pressable's touches and the button would never fire.
|
||||
*/
|
||||
function IconActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
onPress,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
onPress: () => void;
|
||||
style?:
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isNative) {
|
||||
return (
|
||||
<ModelBrowserPressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ModelBrowserPressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
|
||||
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={iconButtonStyle}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
|
||||
[visible],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
// Hidden in style so every pressable branch that applies the style also stops taking taps.
|
||||
profileActionHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -51,10 +51,7 @@ import {
|
||||
TERMINAL_SELECTION_GRIP_SIZE,
|
||||
} from "../terminal/native-renderer/terminal-selection-controller";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
import {
|
||||
forwardNativeTerminalKey,
|
||||
type NativeTerminalKey,
|
||||
} from "../terminal/native-renderer/terminal-key-events";
|
||||
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
|
||||
import {
|
||||
TerminalInput,
|
||||
type TerminalInputHandle,
|
||||
@@ -511,9 +508,9 @@ function NativeTerminalEmulator({
|
||||
);
|
||||
|
||||
const handleNativeTerminalKey = useCallback(
|
||||
(key: NativeTerminalKey) => {
|
||||
(event: NativeTerminalKeyEvent) => {
|
||||
claimActiveTerminalSizeForAction("key");
|
||||
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
|
||||
callbacksRef.current.onTerminalKey?.(event);
|
||||
},
|
||||
[claimActiveTerminalSizeForAction],
|
||||
);
|
||||
|
||||
@@ -88,6 +88,18 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("waits to present until the sheet controller becomes available", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
@@ -120,6 +120,11 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
setPhase("presented");
|
||||
}
|
||||
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
|
||||
// finishes opening. The owner wants it hidden, so it goes again.
|
||||
if (!visible && isEnabled !== false) {
|
||||
dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftCreationKey } from "./creation-key";
|
||||
|
||||
const request = {
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
workspaceId: "workspace-1",
|
||||
initialPrompt: "Start the original task.",
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
};
|
||||
|
||||
describe("draftCreationKey", () => {
|
||||
it("keeps the key for a retry of the same request, whatever its key order", () => {
|
||||
const reordered = {
|
||||
clientMessageId: request.clientMessageId,
|
||||
initialPrompt: request.initialPrompt,
|
||||
workspaceId: request.workspaceId,
|
||||
config: { model: "gpt-6", cwd: "/repo", provider: "codex" },
|
||||
};
|
||||
expect(draftCreationKey("draft-1", reordered)).toBe(draftCreationKey("draft-1", request));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "prompt", edited: { ...request, initialPrompt: "Start the edited task." } },
|
||||
{ name: "model", edited: { ...request, config: { ...request.config, model: "gpt-6-mini" } } },
|
||||
])("gives an edited $name a new key on the same draft", ({ edited }) => {
|
||||
const key = draftCreationKey("draft-1", edited);
|
||||
expect(key).not.toBe(draftCreationKey("draft-1", request));
|
||||
expect(key.startsWith("draft-1:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The creation key for a draft's request. The daemon keeps one request per key, so retrying the
|
||||
* same draft resumes its creation, while an edited draft is a new request with its own key.
|
||||
*/
|
||||
export function draftCreationKey(draftId: string, request: object): string {
|
||||
return `${draftId}:${hash(canonicalJson(request))}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, entry: unknown) =>
|
||||
entry && typeof entry === "object" && !Array.isArray(entry)
|
||||
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
// 53-bit cyrb53: distinguishes the edits of one draft without WebCrypto, which needs HTTPS.
|
||||
function hash(text: string): string {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
h1 = Math.imul(h1 ^ code, 2654435761);
|
||||
h2 = Math.imul(h2 ^ code, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { draftCreationKey } from "@/composer/draft/creation-key";
|
||||
|
||||
interface AutoSubmitConfig {
|
||||
provider: string;
|
||||
@@ -164,8 +165,7 @@ async function submitDraftCreateRequest(input: {
|
||||
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const imagesData = await encodeImages(images);
|
||||
const options = {
|
||||
idempotencyKey: input.draftId,
|
||||
const request = {
|
||||
config,
|
||||
workspaceId,
|
||||
initialPrompt: text,
|
||||
@@ -174,8 +174,10 @@ async function submitDraftCreateRequest(input: {
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
|
||||
const result = creation ? await creation.retry(options) : await client.createAgent(options);
|
||||
const result = await client.createAgent({
|
||||
...request,
|
||||
idempotencyKey: draftCreationKey(input.draftId, request),
|
||||
});
|
||||
|
||||
return {
|
||||
agentId: result.id,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllDirectoryDemand } from "./use-directory-demand";
|
||||
|
||||
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||
serverId: string;
|
||||
@@ -19,6 +20,9 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
const NO_AGENT_MAPS: Record<string, Map<string, Agent> | undefined> = Object.freeze({});
|
||||
const NO_AGENTS: AggregatedAgent[] = [];
|
||||
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
demand?: boolean;
|
||||
@@ -28,19 +32,21 @@ export function useAggregatedAgents(options?: {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const demand = options?.demand ?? true;
|
||||
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
|
||||
useEffect(() => {
|
||||
if (!demand) return;
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [demand, runtime, serverIds]);
|
||||
useAllDirectoryDemand({ serverIds, enabled: demand });
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
// The caller has said it does not need the directory, so stay off the agent maps entirely:
|
||||
// every map change otherwise re-renders this hook and rebuilds the whole list for a surface
|
||||
// that is not on screen (the command center while it is closed).
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
if (!demand) {
|
||||
return NO_AGENT_MAPS;
|
||||
}
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
@@ -64,6 +70,16 @@ export function useAggregatedAgents(options?: {
|
||||
const result = useMemo(() => {
|
||||
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
|
||||
void runtimeVersion;
|
||||
if (!demand) {
|
||||
prevAgentsRef.current = new Map();
|
||||
prevSortedRef.current = NO_AGENTS;
|
||||
return {
|
||||
agents: NO_AGENTS,
|
||||
isLoading: false,
|
||||
isInitialLoad: false,
|
||||
isRevalidating: false,
|
||||
};
|
||||
}
|
||||
const allAgents: AggregatedAgent[] = [];
|
||||
const serverLabelById = new Map(
|
||||
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
|
||||
@@ -158,7 +174,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -14,3 +14,21 @@ export function useDirectoryDemand(serverId: string | null | undefined): void {
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}, [serverId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds directory demand for several hosts at once, so a caller that needs the whole fleet
|
||||
* (the favicon, the aggregated agent list) does not have to subscribe to every agent map to
|
||||
* keep the directories loading.
|
||||
*/
|
||||
export function useAllDirectoryDemand(input: {
|
||||
serverIds: readonly string[];
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const { serverIds, enabled } = input;
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const runtime = getHostRuntimeStore();
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [enabled, serverIds]);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { useAllDirectoryDemand } from "./use-directory-demand";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
|
||||
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
|
||||
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
|
||||
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
@@ -24,21 +25,6 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
function deriveFaviconStatus(
|
||||
agents: ReturnType<typeof useAggregatedAgents>["agents"],
|
||||
): FaviconStatus {
|
||||
const hasRunning = agents.some((agent) => agent.status === "running");
|
||||
if (hasRunning) {
|
||||
return "running";
|
||||
}
|
||||
const hasAttention = agents.some((agent) => agent.requiresAttention);
|
||||
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
|
||||
if (hasAttention || hasNeedsInput) {
|
||||
return "attention";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||
const image = FAVICON_IMAGES[colorScheme][status];
|
||||
if (typeof image === "object" && "uri" in image) {
|
||||
@@ -94,8 +80,15 @@ async function updateMacDockBadge(count?: number) {
|
||||
}
|
||||
|
||||
export function useFaviconStatus() {
|
||||
const { agents } = useAggregatedAgents({ demand: !isNative });
|
||||
const workspaceStatuses = useWorkspaceStatusesForBadges();
|
||||
const daemons = useHosts();
|
||||
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
|
||||
// The favicon reads the whole fleet, so it has to keep every host's directory loading even
|
||||
// though it no longer subscribes to the agent maps.
|
||||
useAllDirectoryDemand({ serverIds, enabled: !isNative });
|
||||
const selectFaviconStatus = useMemo(createFaviconStatusSelector, []);
|
||||
const selectMacDockBadgeCount = useMemo(createMacDockBadgeCountSelector, []);
|
||||
const status = useSessionStore(selectFaviconStatus);
|
||||
const dockBadgeCount = useSessionStore(selectMacDockBadgeCount);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
@@ -112,17 +105,15 @@ export function useFaviconStatus() {
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Update favicon when agents or color scheme changes
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
|
||||
const status = deriveFaviconStatus(agents);
|
||||
updateFavicon(status, colorScheme);
|
||||
}, [status, colorScheme]);
|
||||
|
||||
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
|
||||
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}
|
||||
}, [agents, colorScheme, workspaceStatuses]);
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}, [dockBadgeCount]);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export {
|
||||
subscribeOverlays,
|
||||
} from "./internal/overlay-dispatch";
|
||||
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
|
||||
export { subscribeHardwareKeyboard } from "./internal/sources/hardware";
|
||||
export type { HardwareKeyboardEvent } from "./internal/sources/hardware";
|
||||
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
|
||||
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
|
||||
export { useHardwareKeyboardPresent } from "./availability";
|
||||
|
||||
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
|
||||
|
||||
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
|
||||
const surfaces = new Map<symbol, KeyboardFocusSurface>();
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
declare(surface: KeyboardFocusSurface) {
|
||||
const token = Symbol(surface);
|
||||
surfaces.set(token, surface);
|
||||
notify();
|
||||
return () => {
|
||||
surfaces.delete(token);
|
||||
if (surfaces.delete(token)) notify();
|
||||
};
|
||||
},
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
|
||||
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
|
||||
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
|
||||
for (const surface of surfaces.values()) focused = surface;
|
||||
return focused;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
subscribeCommandCenter(listener: () => void): () => void;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
},
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
|
||||
// so there is no character to take from it.
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
|
||||
module.setClaimedShortcuts(shortcuts);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
|
||||
isComposing: boolean;
|
||||
phase: "down" | "up";
|
||||
}
|
||||
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
|
||||
export interface ClaimedShortcut {
|
||||
code: string;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
export function subscribeHardwareKeyboard(
|
||||
_handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
return () => {};
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
|
||||
import {
|
||||
setHardwareKeyboardClaimedShortcuts,
|
||||
setHardwareKeyboardOverlayKeys,
|
||||
subscribeHardwareKeyboard,
|
||||
} from "./hardware";
|
||||
import { createHardwareKeyboardSource } from "./native";
|
||||
const hardwareKeyboard = createHardwareKeyboardSource({
|
||||
subscribe: subscribeHardwareKeyboard,
|
||||
setOverlayKeys: setHardwareKeyboardOverlayKeys,
|
||||
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
|
||||
});
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
return hardwareKeyboard.attach(handlers);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
|
||||
hardwareKeyboard.publishPolicy(input);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
? buildBrowserKeyboardPolicy(input)
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,92 @@
|
||||
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import {
|
||||
nativeKeyboardInput,
|
||||
type KeyboardPolicyInput,
|
||||
type KeyboardSourceHandlers,
|
||||
} from "./contract";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
}
|
||||
export function attachHardwareKeyboardSource(
|
||||
source: HardwareKeySource,
|
||||
handlers: KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateKeys();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
|
||||
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
|
||||
|
||||
/**
|
||||
* The shortcuts a focused text control would otherwise type or act on.
|
||||
*
|
||||
* The native window observes every press but cannot wait for the pipeline to
|
||||
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
|
||||
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
|
||||
* combos do not type, and the platform's own editing combos stay with the text
|
||||
* control through `editable: false`. Unmodified keys belong to overlays and the
|
||||
* composer's Return.
|
||||
*/
|
||||
function claimedShortcuts(
|
||||
policy: KeyboardPolicyInput | null,
|
||||
commandCenterOpen: boolean,
|
||||
): ClaimedShortcut[] {
|
||||
if (!policy?.enabled) return [];
|
||||
const context = {
|
||||
isMac: policy.isMac,
|
||||
usesDesktopBindings: policy.usesDesktopBindings,
|
||||
commandCenterOpen,
|
||||
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
|
||||
};
|
||||
const claimed = new Map<string, ClaimedShortcut>();
|
||||
for (const binding of policy.bindings) {
|
||||
const combo = binding.parsedChord[0];
|
||||
if (!combo || binding.preventDefault === false) continue;
|
||||
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
|
||||
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
|
||||
const shift = combo.shift === true;
|
||||
const alt = combo.alt === true;
|
||||
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
|
||||
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
}
|
||||
return [...claimed.values()];
|
||||
}
|
||||
|
||||
export function createHardwareKeyboardSource(source: HardwareKeySource) {
|
||||
let policy: KeyboardPolicyInput | null = null;
|
||||
let updateClaims = () => {};
|
||||
return {
|
||||
publishPolicy(next: KeyboardPolicyInput) {
|
||||
policy = next;
|
||||
updateClaims();
|
||||
},
|
||||
attach(handlers: KeyboardSourceHandlers): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateClaims = () =>
|
||||
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
|
||||
updateKeys();
|
||||
updateClaims();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
|
||||
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeFocus();
|
||||
unsubscribeCommandCenter();
|
||||
unsubscribeOverlays();
|
||||
updateClaims = () => {};
|
||||
source.setOverlayKeys([]);
|
||||
source.setClaimedShortcuts([]);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+[",
|
||||
event: { key: "{", code: "BracketLeft", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
},
|
||||
{
|
||||
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+]",
|
||||
event: { key: "}", code: "BracketRight", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches Mod+T to open new tab",
|
||||
event: { key: "t", code: "KeyT", metaKey: true },
|
||||
@@ -479,6 +493,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not switch tabs with Alt+Shift+] on mac desktop (Cmd+Shift+] only)",
|
||||
event: { key: "’", code: "BracketRight", altKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+T on mac (Cmd only)",
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
@@ -1306,6 +1325,17 @@ describe("unassigned shortcuts", () => {
|
||||
expect(getBindingIdForAction("workspace-tab-new", desktopNonMac)).toBe(TAB_NEW_BINDING);
|
||||
});
|
||||
|
||||
it("advertises Cmd+Shift+brackets for tab navigation on macOS desktop", () => {
|
||||
const desktopMac = { isMac: true, usesDesktopBindings: true };
|
||||
|
||||
expect(getDefaultKeysForAction("workspace-tab-prev", desktopMac)).toEqual([
|
||||
["mod", "shift", "["],
|
||||
]);
|
||||
expect(getDefaultKeysForAction("workspace-tab-next", desktopMac)).toEqual([
|
||||
["mod", "shift", "]"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("still lists the default keys when nothing is overridden", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
|
||||
|
||||
|
||||
@@ -715,11 +715,37 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- Tab relative navigation ---
|
||||
// macOS desktop uses Cmd+Shift+brackets; the browser owns those keys, so
|
||||
// macOS web keeps Alt+Shift alongside every non-mac platform.
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-cmd-shift-left-mac",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Cmd+Shift+[",
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "tabs-panes",
|
||||
label: "Previous tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-cmd-shift-right-mac",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Cmd+Shift+]",
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "tabs-panes",
|
||||
label: "Next tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+[",
|
||||
when: { commandCenter: false },
|
||||
when: { mac: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
@@ -731,7 +757,31 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+]",
|
||||
when: { commandCenter: false },
|
||||
when: { mac: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "tabs-panes",
|
||||
label: "Next tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left-mac-web",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+[",
|
||||
when: { mac: true, desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "tabs-panes",
|
||||
label: "Previous tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right-mac-web",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+]",
|
||||
when: { mac: true, desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
|
||||
it.each([true, false])(
|
||||
"delivers hardware down/up and declared focus with mac=%s",
|
||||
async (isMac) => {
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
const detach = createHardwareKeyboardSource({
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts() {},
|
||||
}).attach({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
|
||||
expect(events.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
let claimed: string[] = [];
|
||||
const source = createHardwareKeyboardSource({
|
||||
subscribe: () => () => {},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts(shortcuts) {
|
||||
claimed = shortcuts
|
||||
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
|
||||
.map((parts) => parts.filter(Boolean).join("+"))
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
let commandCenterOpen = false;
|
||||
const commandCenterListeners = new Set<() => void>();
|
||||
const detach = source.attach({
|
||||
keyDown() {},
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => commandCenterOpen,
|
||||
subscribeCommandCenter(listener) {
|
||||
commandCenterListeners.add(listener);
|
||||
return () => commandCenterListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
const setCommandCenterOpen = (open: boolean) => {
|
||||
commandCenterOpen = open;
|
||||
for (const listener of commandCenterListeners) listener();
|
||||
};
|
||||
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
const releaseComposer = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
setCommandCenterOpen(true);
|
||||
expect(claimed).toEqual([]);
|
||||
setCommandCenterOpen(false);
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
releaseComposer();
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseRebound = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Alt+KeyM"]);
|
||||
releaseRebound();
|
||||
|
||||
// A chord's later step is only a shortcut while the chord is in progress;
|
||||
// claiming it always would stop the composer typing a capital A.
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseChord = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual([]);
|
||||
releaseChord();
|
||||
|
||||
const releaseFinal = focusSurfaces.declare("message-input");
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(claimed).toEqual([]);
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
detach();
|
||||
expect(claimed).toEqual([]);
|
||||
releaseFinal();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
|
||||
keyUp: handleKeyUp,
|
||||
blur: resetModifiers,
|
||||
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
subscribeCommandCenter: (listener) =>
|
||||
useKeyboardShortcutsStore.subscribe((state, previous) => {
|
||||
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -126,7 +123,6 @@ import {
|
||||
} from "./workspace/terminals/state";
|
||||
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
import { retriedAgentRequest } from "./new-workspace/retry-agent-request";
|
||||
|
||||
function useIsNewWorkspaceDraftHandoffActive(input: {
|
||||
draftId: string | undefined;
|
||||
@@ -243,7 +239,6 @@ interface SubmitDraftInput {
|
||||
clearConsumedDraft: () => void;
|
||||
agentCreation?: {
|
||||
result: Promise<AgentSnapshotPayload>;
|
||||
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
|
||||
};
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
@@ -471,13 +466,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
images: images?.length ? images : undefined,
|
||||
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
|
||||
};
|
||||
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
|
||||
const execute = async (): Promise<AgentSnapshotPayload> => {
|
||||
const { agent } = await ensureWorkspace({
|
||||
cwd,
|
||||
prompt: text,
|
||||
attachments: workspaceNamingAttachments,
|
||||
withInitialAgent: true,
|
||||
agent: requestedAgent,
|
||||
agent: initialAgent,
|
||||
onEvent: (snapshot) => {
|
||||
if (!snapshot.workspace || navigated) return;
|
||||
navigated = true;
|
||||
@@ -516,11 +511,8 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
if (!agent) throw new Error("Workspace creation returned no agent");
|
||||
return agent;
|
||||
};
|
||||
const agentCreation = {
|
||||
result: Promise.resolve().then(() => execute()),
|
||||
retry: (request: CreateAgentRequestOptions) =>
|
||||
execute(retriedAgentRequest(initialAgent, request, cwd)),
|
||||
};
|
||||
// A retry after a failed agent start belongs to the ready workspace's draft, not to this creation.
|
||||
const agentCreation = { result: Promise.resolve().then(() => execute()) };
|
||||
await agentCreation.result;
|
||||
if (outcome === "background") clearConsumedDraft();
|
||||
return outcome;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request";
|
||||
|
||||
const initial: WorkspaceAgentRequest = {
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
metadata: { "issues.key": "PAS-1" },
|
||||
initialPrompt: "first try",
|
||||
clientMessageId: "draft_1:initial-message",
|
||||
};
|
||||
|
||||
describe("retriedAgentRequest", () => {
|
||||
it("creates with the metadata the draft resubmitted", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
initialPrompt: "second try",
|
||||
metadata: { "issues.key": "PAS-2" },
|
||||
},
|
||||
"/repo",
|
||||
);
|
||||
expect(retried.metadata).toEqual({ "issues.key": "PAS-2" });
|
||||
expect(retried.initialPrompt).toBe("second try");
|
||||
expect(retried.clientMessageId).toBe("draft_1:initial-message");
|
||||
});
|
||||
|
||||
it("creates with no metadata once the draft cleared it", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{ config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" },
|
||||
"/repo",
|
||||
);
|
||||
expect("metadata" in retried).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first attempt's directory over the resubmitted config", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{
|
||||
config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" },
|
||||
initialPrompt: "second try",
|
||||
},
|
||||
"/repo",
|
||||
);
|
||||
expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" });
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
|
||||
|
||||
/**
|
||||
* The agent a retried creation asks for: everything the draft resubmitted (config, prompt,
|
||||
* images, attachments and metadata, cleared included) on the first attempt's identity, so a
|
||||
* retry creates what the composer shows now, not what it showed when creation first failed.
|
||||
*/
|
||||
export function retriedAgentRequest(
|
||||
initial: WorkspaceAgentRequest,
|
||||
request: CreateAgentRequestOptions,
|
||||
cwd: string,
|
||||
): WorkspaceAgentRequest {
|
||||
const { metadata: _replaced, ...identity } = initial;
|
||||
return {
|
||||
...identity,
|
||||
config: { ...request.config!, cwd },
|
||||
initialPrompt: request.initialPrompt ?? "",
|
||||
clientMessageId: initial.clientMessageId,
|
||||
images: request.images,
|
||||
attachments: request.attachments,
|
||||
...(request.metadata ? { metadata: request.metadata } : {}),
|
||||
};
|
||||
}
|
||||
@@ -17,22 +17,16 @@ import {
|
||||
selectWorkspaceExists,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
workspaceEqualityFns,
|
||||
type WorkspaceStructure,
|
||||
} from "./selectors";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
|
||||
// These are the ONLY supported ways to read workspaces from the session store.
|
||||
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
|
||||
// or the sessions dict — it breaks re-render isolation.
|
||||
|
||||
export type {
|
||||
DesktopBadgeWorkspaceStatus,
|
||||
WorkspaceStructure,
|
||||
WorkspaceStructureProject,
|
||||
} from "./selectors";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "./selectors";
|
||||
|
||||
export function useWorkspace(
|
||||
serverId: string | null,
|
||||
@@ -163,11 +157,3 @@ export function useHasWorkspaces(serverId: string | null): boolean {
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceStatusesForBadges(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
selectWorkspaceStructureProjects,
|
||||
workspaceEqualityFns,
|
||||
type SidebarOrderSnapshot,
|
||||
@@ -589,36 +588,6 @@ describe("selectHasWorkspaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectWorkspaceStatusesForBadges", () => {
|
||||
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const tracked = trackSelector(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceStatusesForBadges(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const before = tracked.current;
|
||||
expect(before).toEqual(["done", "attention"]);
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
|
||||
expect(tracked.current).not.toBe(before);
|
||||
expect(tracked.current).toEqual(["failed", "attention"]);
|
||||
|
||||
tracked.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("a prompt changes only its project's structure and output preserves both project identities", () => {
|
||||
const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") };
|
||||
const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") };
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
type WorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
|
||||
export interface SessionsSnapshot {
|
||||
@@ -321,15 +319,3 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
|
||||
}
|
||||
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function selectWorkspaceStatusesForBadges(
|
||||
state: SessionsSnapshot,
|
||||
): DesktopBadgeWorkspaceStatus[] {
|
||||
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const workspace of session.workspaces.values()) {
|
||||
statuses.push(workspace.status);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
|
||||
expect(store.getState().consumePending(submission)).toBeNull();
|
||||
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
|
||||
});
|
||||
|
||||
test("consuming draft presentation retains its running creation through remount and retry", () => {
|
||||
const store = loadProductionStore();
|
||||
const result = new Promise<never>(() => {});
|
||||
const creation = { result, retry: () => result };
|
||||
const submission = {
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
draftId: "draft-one",
|
||||
text: "hello",
|
||||
attachments: [],
|
||||
cwd: "/repo",
|
||||
provider: "codex",
|
||||
clientMessageId: "message",
|
||||
timestamp: 1,
|
||||
agentCreation: creation,
|
||||
};
|
||||
store.getState().setPending(submission);
|
||||
store.getState().consumePending(submission);
|
||||
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
|
||||
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
|
||||
store.getState().clearDraftSetup({ draftId: submission.draftId });
|
||||
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
@@ -9,7 +8,6 @@ export interface PendingWorkspaceDraftSubmission {
|
||||
/** Already-running creation. Mounting the draft only observes its result. */
|
||||
agentCreation?: {
|
||||
result: Promise<AgentSnapshotPayload>;
|
||||
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
|
||||
};
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -35,7 +33,6 @@ export interface PendingWorkspaceDraftSetup {
|
||||
interface WorkspaceDraftSubmissionState {
|
||||
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
||||
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
|
||||
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
|
||||
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
||||
setDraftSetup: (input: {
|
||||
draftId: string;
|
||||
@@ -69,12 +66,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
(set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setupByDraftId: {},
|
||||
creationByDraftId: {},
|
||||
setPending: (submission) =>
|
||||
set((state) => ({
|
||||
creationByDraftId: submission.agentCreation
|
||||
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
|
||||
: state.creationByDraftId,
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[submission.draftId]: submission,
|
||||
@@ -94,10 +87,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => {
|
||||
const creationByDraftId = { ...state.creationByDraftId };
|
||||
delete creationByDraftId[normalizedDraftId];
|
||||
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
|
||||
return { setupByDraftId, creationByDraftId };
|
||||
return { setupByDraftId };
|
||||
});
|
||||
},
|
||||
consumePending: (input) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
|
||||
|
||||
const label = "paseo.open-agent-tab.client-1";
|
||||
|
||||
@@ -51,3 +52,54 @@ describe("getAgentTabsNeedingOpenLabel", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectOpenTabAgents", () => {
|
||||
function stateWith(agents: Agent[]) {
|
||||
return {
|
||||
sessions: {
|
||||
srv: { agents: new Map(agents.map((entry) => [entry.id, entry])), agentDetails: new Map() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stays equal when only an agent without an open tab changes", () => {
|
||||
const open = agent({ id: "open" });
|
||||
const before = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
const after = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
|
||||
expect(shallow(before, after)).toBe(true);
|
||||
});
|
||||
|
||||
it("changes when an agent with an open tab changes", () => {
|
||||
const elsewhere = agent({ id: "elsewhere" });
|
||||
const before = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
const after = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
|
||||
expect(shallow(before, after)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to agent details and returns null for unknown agents", () => {
|
||||
const detail = agent({ id: "detail" });
|
||||
const state = {
|
||||
sessions: {
|
||||
srv: { agents: new Map<string, Agent>(), agentDetails: new Map([["detail", detail]]) },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
selectOpenTabAgents(state, { serverId: "srv", agentIds: ["detail", "missing"] }),
|
||||
).toEqual([detail, null]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,3 +23,24 @@ export function getAgentTabsNeedingOpenLabel(input: {
|
||||
}
|
||||
return [...agentIds];
|
||||
}
|
||||
|
||||
interface OpenTabAgentSource {
|
||||
sessions: Record<
|
||||
string,
|
||||
{ agents: ReadonlyMap<string, Agent>; agentDetails: ReadonlyMap<string, Agent> } | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents behind the given open tabs, in tab order. Compared element by element, this changes
|
||||
* only when one of those agents changes, not on updates to other agents on the host.
|
||||
*/
|
||||
export function selectOpenTabAgents(
|
||||
state: OpenTabAgentSource,
|
||||
input: { serverId: string; agentIds: readonly string[] },
|
||||
): (Agent | null)[] {
|
||||
const session = state.sessions[input.serverId];
|
||||
return input.agentIds.map(
|
||||
(agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
|
||||
|
||||
function increment(value: number): number {
|
||||
return value + 1;
|
||||
@@ -16,9 +17,16 @@ export function useOpenAgentTabLabels(input: {
|
||||
tabs: WorkspaceTab[];
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
|
||||
const agentDetails = useSessionStore(
|
||||
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
|
||||
const openAgentIds = useMemo(
|
||||
() => input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||
[input.tabs],
|
||||
);
|
||||
// Only the agents with open tabs can need a label. Subscribing to the whole agent map would
|
||||
// re-render the workspace screen on every update to any agent on the host.
|
||||
const openAgents = useSessionStore(
|
||||
useShallow((state) =>
|
||||
selectOpenTabAgents(state, { serverId: input.serverId, agentIds: openAgentIds }),
|
||||
),
|
||||
);
|
||||
const pendingAgentIdsRef = useRef(new Set<string>());
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -39,15 +47,13 @@ export function useOpenAgentTabLabels(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const openAgentIds = new Set(
|
||||
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||
);
|
||||
const agentsById = new Map(openAgentIds.map((agentId, index) => [agentId, openAgents[index]]));
|
||||
for (const agentId of pendingAgentIdsRef.current) {
|
||||
if (!openAgentIds.has(agentId)) {
|
||||
if (!agentsById.has(agentId)) {
|
||||
pendingAgentIdsRef.current.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (openAgentIds.size === 0) {
|
||||
if (agentsById.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +63,7 @@ export function useOpenAgentTabLabels(input: {
|
||||
const label = getOpenAgentTabLabel(clientId);
|
||||
const agentIds = getAgentTabsNeedingOpenLabel({
|
||||
tabs: input.tabs,
|
||||
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
|
||||
getAgent: (agentId) => agentsById.get(agentId),
|
||||
label,
|
||||
pendingAgentIds: pendingAgentIdsRef.current,
|
||||
});
|
||||
@@ -82,5 +88,5 @@ export function useOpenAgentTabLabels(input: {
|
||||
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
|
||||
}
|
||||
})();
|
||||
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
|
||||
}, [input.client, input.enabled, input.tabs, openAgentIds, openAgents, retryVersion]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TERMINAL_INPUT_CONTEXT_MENU_HIDDEN,
|
||||
TERMINAL_INPUT_HITBOX_SIZE,
|
||||
} from "./terminal-input.native";
|
||||
import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events";
|
||||
import { createNativeTerminalKeyEvent, resolveHardwareTerminalKey } from "./terminal-key-events";
|
||||
|
||||
describe("native terminal typed input", () => {
|
||||
function inputData(change: { data: string; shouldClear: boolean }): string {
|
||||
@@ -149,25 +149,18 @@ describe("native terminal typed input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native arrow key events to the terminal key path", () => {
|
||||
const events: NativeTerminalKeyEvent[] = [];
|
||||
|
||||
forwardNativeTerminalKey({
|
||||
key: "ArrowUp",
|
||||
onTerminalKey: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
it("identifies hardware arrows by code and keeps their modifiers", () => {
|
||||
// UIKit leaves `key` as a sentinel for Left/Right, so the code leads.
|
||||
expect(resolveHardwareTerminalKey({ key: "\uF702", code: "ArrowLeft" })).toBe("ArrowLeft");
|
||||
expect(resolveHardwareTerminalKey({ key: "ArrowUp", code: "" })).toBe("ArrowUp");
|
||||
expect(resolveHardwareTerminalKey({ key: "a", code: "KeyA" })).toBeNull();
|
||||
expect(createNativeTerminalKeyEvent("ArrowDown", { shift: true, alt: true })).toEqual({
|
||||
key: "ArrowDown",
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: true,
|
||||
meta: false,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
key: "ArrowUp",
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the hidden input from owning terminal long-press selection", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
type NativeSyntheticEvent,
|
||||
type StyleProp,
|
||||
@@ -10,10 +11,19 @@ import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
|
||||
import { hasActiveOverlay, subscribeHardwareKeyboard } from "@/keyboard";
|
||||
import {
|
||||
createNativeTerminalKeyEvent,
|
||||
resolveHardwareTerminalKey,
|
||||
resolveNativeTerminalKey,
|
||||
type NativeTerminalKey,
|
||||
type NativeTerminalKeyEvent,
|
||||
} from "./terminal-key-events";
|
||||
|
||||
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
|
||||
export const TERMINAL_INPUT_HITBOX_SIZE = 1;
|
||||
/** Matched by the Android hardware keyboard module to claim arrow keys for this input. */
|
||||
export const TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input";
|
||||
|
||||
export interface TerminalTextInputChange {
|
||||
data: string;
|
||||
@@ -39,7 +49,7 @@ interface TerminalInputProps {
|
||||
isKeyboardVisible: boolean;
|
||||
onFocus?: () => void;
|
||||
onInput?: (data: string) => void;
|
||||
onTerminalKey?: (key: NativeTerminalKey) => void;
|
||||
onTerminalKey?: (event: NativeTerminalKeyEvent) => void;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
@@ -187,6 +197,8 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||
const onTerminalKeyRef = useRef(onTerminalKey);
|
||||
onTerminalKeyRef.current = onTerminalKey;
|
||||
const inputState = useMemo(() => createTerminalTextInputState(), []);
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
@@ -250,6 +262,27 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
|
||||
useEffect(() => clearPendingFocus, [clearPendingFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
// Physical arrows arrive through the native key stream, never through
|
||||
// TextInput.onKeyPress (RN builds that event from the IME connection).
|
||||
// The native module claims them for this input so the hidden buffer
|
||||
// cannot move its caret, then re-emits them here for the PTY.
|
||||
if (Platform.OS !== "android") return;
|
||||
return subscribeHardwareKeyboard((event) => {
|
||||
if (!isFocusedRef.current || event.phase !== "down" || event.isComposing) return;
|
||||
if (hasActiveOverlay()) return;
|
||||
const key = resolveHardwareTerminalKey(event);
|
||||
if (!key) return;
|
||||
onTerminalKeyRef.current?.({
|
||||
key,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -293,7 +326,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const change = inputState.receiveKeyPress(event.nativeEvent.key);
|
||||
if (change.key) {
|
||||
onTerminalKey?.(change.key);
|
||||
onTerminalKey?.(createNativeTerminalKeyEvent(change.key));
|
||||
}
|
||||
if (change.data.length > 0) {
|
||||
onInput?.(change.data);
|
||||
@@ -321,6 +354,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
blurOnSubmit={false}
|
||||
importantForAutofill="no"
|
||||
multiline={true}
|
||||
nativeID={TERMINAL_INPUT_NATIVE_ID}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
|
||||
@@ -22,19 +22,26 @@ export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
|
||||
return {
|
||||
key,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
};
|
||||
/**
|
||||
* Physical keyboards report the DOM `code`. UIKit leaves `key` as a sentinel
|
||||
* for Left/Right, so the code is the only reliable identifier there.
|
||||
*/
|
||||
export function resolveHardwareTerminalKey(input: {
|
||||
key?: string;
|
||||
code?: string;
|
||||
}): NativeTerminalKey | null {
|
||||
return resolveNativeTerminalKey(input.code ?? "") ?? resolveNativeTerminalKey(input.key ?? "");
|
||||
}
|
||||
|
||||
export function forwardNativeTerminalKey(input: {
|
||||
key: NativeTerminalKey;
|
||||
onTerminalKey?: NativeTerminalKeyHandler;
|
||||
}): void {
|
||||
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
|
||||
export function createNativeTerminalKeyEvent(
|
||||
key: NativeTerminalKey,
|
||||
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {},
|
||||
): NativeTerminalKeyEvent {
|
||||
return {
|
||||
key,
|
||||
ctrl: modifiers.ctrl ?? false,
|
||||
shift: modifiers.shift ?? false,
|
||||
alt: modifiers.alt ?? false,
|
||||
meta: modifiers.meta ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,26 @@ interface CreateTerminalHostInput {
|
||||
isMac?: boolean;
|
||||
}
|
||||
|
||||
/** Text typed into xterm's textarea, as the browser reports a typed character. */
|
||||
function typeText(mounted: MountedTerminal, text: string): void {
|
||||
mounted.host
|
||||
.querySelector(".xterm-helper-textarea")
|
||||
?.dispatchEvent(
|
||||
new InputEvent("input", { data: text, inputType: "insertText", bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** A native paste of plain text into xterm's textarea. */
|
||||
function pasteText(mounted: MountedTerminal, text: string): void {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", text);
|
||||
mounted.host
|
||||
.querySelector(".xterm-helper-textarea")
|
||||
?.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
const root = document.createElement("div");
|
||||
root.style.width = `${input.width}px`;
|
||||
@@ -824,6 +844,23 @@ describe("incremental replay lifecycle", () => {
|
||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps what the user types, but not pastes, while a replay is still parsing", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.runtime.replay({ type: "begin" });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07restored") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
|
||||
typeText(mounted, "a");
|
||||
pasteText(mounted, "echo one\necho two\n");
|
||||
typeText(mounted, "f");
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
|
||||
);
|
||||
|
||||
expect(mounted.inputs).toEqual(["a", "f"]);
|
||||
});
|
||||
|
||||
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const text = "A😀B❤️C👋🏽D👨👩👧E";
|
||||
|
||||
@@ -124,6 +124,7 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeFilePasteListener: () => void;
|
||||
removeUserInputListeners: () => void;
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
@@ -157,6 +158,10 @@ const isAppleHandheld =
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
|
||||
// DOM events xterm turns into typed data on its textarea. Pastes stay suppressed during a
|
||||
// replay: until the snapshot restores bracketed paste mode, a multi-line paste would run
|
||||
// line by line in the shell.
|
||||
const USER_INPUT_EVENT_TYPES = ["keydown", "keypress", "input"] as const;
|
||||
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
|
||||
const terminalOutputEncoder = new TextEncoder();
|
||||
|
||||
@@ -313,6 +318,8 @@ export class TerminalEmulatorRuntime {
|
||||
private hasUngatedWrites = false;
|
||||
private readonly inputModeDecoder = new TextDecoder();
|
||||
private suppressInput = false;
|
||||
// The latest user input event the host saw; its eventPhase is NONE once dispatch ends.
|
||||
private userInputEvent: Event | null = null;
|
||||
private readonly inputModeTracker = new TerminalInputModeTracker();
|
||||
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
|
||||
private themeBackgroundElements: HTMLElement[] = [];
|
||||
@@ -739,8 +746,17 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
|
||||
|
||||
// xterm reports what the user types and the replies it generates while parsing
|
||||
// through the same onData. Replies only come out of parsing a write, never while a
|
||||
// user input event is dispatching, so typing survives a replay that suppresses them.
|
||||
const recordUserInputEvent = (event: Event): void => {
|
||||
this.userInputEvent = event;
|
||||
};
|
||||
for (const type of USER_INPUT_EVENT_TYPES) {
|
||||
input.host.addEventListener(type, recordUserInputEvent, true);
|
||||
}
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
if (this.suppressInput) {
|
||||
if (this.suppressInput && !this.isDispatchingUserInput()) {
|
||||
return;
|
||||
}
|
||||
this.callbacks.onInput?.(data);
|
||||
@@ -844,6 +860,12 @@ export class TerminalEmulatorRuntime {
|
||||
removeFilePasteListener: () => {
|
||||
input.host.removeEventListener("paste", filePasteHandler, true);
|
||||
},
|
||||
removeUserInputListeners: () => {
|
||||
for (const type of USER_INPUT_EVENT_TYPES) {
|
||||
input.host.removeEventListener(type, recordUserInputEvent, true);
|
||||
}
|
||||
this.userInputEvent = null;
|
||||
},
|
||||
restoreDocumentStyles,
|
||||
restoreViewportStyles,
|
||||
disposeFitAddon: () => {
|
||||
@@ -873,6 +895,7 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeFilePasteListener();
|
||||
disposables.removeUserInputListeners();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeWebglAddon();
|
||||
disposables.disposeTerminal();
|
||||
@@ -913,6 +936,10 @@ export class TerminalEmulatorRuntime {
|
||||
this.terminal?.paste(text);
|
||||
}
|
||||
|
||||
private isDispatchingUserInput(): boolean {
|
||||
return (this.userInputEvent?.eventPhase ?? Event.NONE) !== Event.NONE;
|
||||
}
|
||||
|
||||
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
|
||||
const text = this.clipboard.readText().catch(() => "");
|
||||
const onFilePaste = this.callbacks.onFilePaste;
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||
createMacDockBadgeCountSelector,
|
||||
isWorkspaceActionableForDesktopBadge,
|
||||
selectMacDockBadgeCount,
|
||||
} from "./desktop-badge-state";
|
||||
|
||||
function workspace(id: string, status: WorkspaceDescriptor["status"]): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status,
|
||||
archivingAt: null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(...workspaces: WorkspaceDescriptor[]) {
|
||||
return {
|
||||
sessions: {
|
||||
srv_a: { workspaces: new Map(workspaces.map((entry) => [entry.id, entry])) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop-badge-state", () => {
|
||||
it("treats attention-requiring workspace statuses as actionable", () => {
|
||||
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
|
||||
@@ -17,18 +47,40 @@ describe("desktop-badge-state", () => {
|
||||
});
|
||||
|
||||
it("returns undefined when no visible workspaces need attention", () => {
|
||||
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
|
||||
expect(
|
||||
selectMacDockBadgeCount(stateWith(workspace("a", "done"), workspace("b", "running"))),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts only actionable visible workspaces", () => {
|
||||
expect(
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses([
|
||||
"done",
|
||||
"attention",
|
||||
"running",
|
||||
"needs_input",
|
||||
"failed",
|
||||
]),
|
||||
).toBe(3);
|
||||
it("counts only actionable visible workspaces across every host", () => {
|
||||
const state = {
|
||||
sessions: {
|
||||
srv_a: {
|
||||
workspaces: new Map([
|
||||
["a", workspace("a", "done")],
|
||||
["b", workspace("b", "attention")],
|
||||
]),
|
||||
},
|
||||
srv_b: {
|
||||
workspaces: new Map([
|
||||
["c", workspace("c", "running")],
|
||||
["d", workspace("d", "needs_input")],
|
||||
["e", workspace("e", "failed")],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(selectMacDockBadgeCount(state)).toBe(3);
|
||||
});
|
||||
|
||||
it("recounts only when a workspace map is replaced", () => {
|
||||
const select = createMacDockBadgeCountSelector();
|
||||
const state = stateWith(workspace("a", "attention"));
|
||||
expect(select(state)).toBe(1);
|
||||
|
||||
state.sessions.srv_a.workspaces.set("b", workspace("b", "failed"));
|
||||
expect(select({ sessions: { ...state.sessions } })).toBe(1);
|
||||
|
||||
expect(select(stateWith(workspace("a", "attention"), workspace("b", "failed")))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
|
||||
|
||||
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
|
||||
|
||||
interface WorkspaceStatusSession {
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
}
|
||||
|
||||
interface WorkspaceStatusSource {
|
||||
sessions: Record<string, WorkspaceStatusSession | undefined>;
|
||||
}
|
||||
|
||||
export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
|
||||
return status === "attention" || status === "needs_input" || status === "failed";
|
||||
}
|
||||
|
||||
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
|
||||
statuses: readonly DesktopBadgeWorkspaceStatus[],
|
||||
): number | undefined {
|
||||
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
|
||||
/**
|
||||
* The macOS dock badge only needs a count, so count here instead of materialising every
|
||||
* workspace status and comparing the whole list on every store write.
|
||||
*/
|
||||
export function selectMacDockBadgeCount(state: WorkspaceStatusSource): number | undefined {
|
||||
let actionableCount = 0;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
if (!session) continue;
|
||||
for (const workspace of session.workspaces.values()) {
|
||||
if (isWorkspaceActionableForDesktopBadge(workspace.status)) actionableCount += 1;
|
||||
}
|
||||
}
|
||||
return actionableCount > 0 ? actionableCount : undefined;
|
||||
}
|
||||
|
||||
/** A session-store selector for the dock badge count that recounts only when a workspace map changes. */
|
||||
export function createMacDockBadgeCountSelector(): (
|
||||
state: WorkspaceStatusSource,
|
||||
) => number | undefined {
|
||||
return memoizeOnSessionMaps<WorkspaceStatusSession, WorkspaceStatusSource, number | undefined>(
|
||||
(session) => session.workspaces,
|
||||
selectMacDockBadgeCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { createFaviconStatusSelector, deriveFaviconStatus } from "./favicon-status";
|
||||
|
||||
function agent(input: Partial<Agent> & { id: string }): Agent {
|
||||
return {
|
||||
status: "idle",
|
||||
background: false,
|
||||
archivedAt: null,
|
||||
requiresAttention: false,
|
||||
pendingPermissions: [],
|
||||
...input,
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
function stateWith(...agents: Agent[]) {
|
||||
return { sessions: { srv: { agents: new Map(agents.map((entry) => [entry.id, entry])) } } };
|
||||
}
|
||||
|
||||
describe("deriveFaviconStatus", () => {
|
||||
it("is running when any visible agent runs", () => {
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(
|
||||
agent({ id: "a", requiresAttention: true }),
|
||||
agent({ id: "b", status: "running" }),
|
||||
),
|
||||
),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("needs attention for attention or pending permissions", () => {
|
||||
expect(deriveFaviconStatus(stateWith(agent({ id: "a", requiresAttention: true })))).toBe(
|
||||
"attention",
|
||||
);
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(agent({ id: "a", pendingPermissions: [{}] as Agent["pendingPermissions"] })),
|
||||
),
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("ignores background and archived agents", () => {
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(
|
||||
agent({ id: "a", status: "running", background: true }),
|
||||
agent({ id: "b", requiresAttention: true, archivedAt: new Date() }),
|
||||
),
|
||||
),
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFaviconStatusSelector", () => {
|
||||
it("reuses its result while the agent maps are unchanged", () => {
|
||||
const select = createFaviconStatusSelector();
|
||||
const state = stateWith(agent({ id: "a", status: "running" }));
|
||||
expect(select(state)).toBe("running");
|
||||
|
||||
// Same agent map, mutated behind the store's back: the selector must not rescan.
|
||||
state.sessions.srv.agents.set("a", agent({ id: "a" }));
|
||||
expect(select({ sessions: { ...state.sessions } })).toBe("running");
|
||||
});
|
||||
|
||||
it("rescans when an agent map is replaced", () => {
|
||||
const select = createFaviconStatusSelector();
|
||||
expect(select(stateWith(agent({ id: "a", status: "running" })))).toBe("running");
|
||||
expect(select(stateWith(agent({ id: "a" })))).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
|
||||
|
||||
export type FaviconStatus = "none" | "running" | "attention";
|
||||
|
||||
interface FaviconAgentSession {
|
||||
agents: ReadonlyMap<string, Agent>;
|
||||
}
|
||||
|
||||
interface FaviconAgentSource {
|
||||
sessions: Record<string, FaviconAgentSession | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The favicon is the only always-mounted consumer of the whole agent directory, so derive its
|
||||
* three states here instead of building the aggregated agent list: the list allocates a
|
||||
* projection per agent, deep-compares it and sorts every agent on the host on each update.
|
||||
*/
|
||||
export function deriveFaviconStatus(state: FaviconAgentSource): FaviconStatus {
|
||||
let hasAttention = false;
|
||||
let hasNeedsInput = false;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
if (!session) continue;
|
||||
for (const agent of session.agents.values()) {
|
||||
if (agent.background || agent.archivedAt) continue;
|
||||
if (agent.status === "running") return "running";
|
||||
if (agent.requiresAttention) hasAttention = true;
|
||||
if (agent.pendingPermissions.length > 0) hasNeedsInput = true;
|
||||
}
|
||||
}
|
||||
return hasAttention || hasNeedsInput ? "attention" : "none";
|
||||
}
|
||||
|
||||
/** A session-store selector for the favicon status that rescans only when an agent map changes. */
|
||||
export function createFaviconStatusSelector(): (state: FaviconAgentSource) => FaviconStatus {
|
||||
return memoizeOnSessionMaps<FaviconAgentSession, FaviconAgentSource, FaviconStatus>(
|
||||
(session) => session.agents,
|
||||
deriveFaviconStatus,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
interface SessionsSource<TSession> {
|
||||
sessions: Record<string, TSession | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a whole-store selector so it reruns only when one of the per-session maps it reads has
|
||||
* changed. Session-store writes that leave those maps alone (timeline updates, drafts) then cost
|
||||
* a reference check per host instead of a walk over every entry.
|
||||
*/
|
||||
export function memoizeOnSessionMaps<TSession, TState extends SessionsSource<TSession>, TResult>(
|
||||
pickMap: (session: TSession) => unknown,
|
||||
derive: (state: TState) => TResult,
|
||||
): (state: TState) => TResult {
|
||||
let previousMaps: unknown[] | null = null;
|
||||
let previousResult: TResult;
|
||||
return (state) => {
|
||||
const maps = Object.values(state.sessions).map((session) =>
|
||||
session ? pickMap(session) : undefined,
|
||||
);
|
||||
if (
|
||||
previousMaps &&
|
||||
previousMaps.length === maps.length &&
|
||||
previousMaps.every((map, index) => map === maps[index])
|
||||
) {
|
||||
return previousResult;
|
||||
}
|
||||
previousMaps = maps;
|
||||
previousResult = derive(state);
|
||||
return previousResult;
|
||||
};
|
||||
}
|
||||
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
/**
|
||||
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
|
||||
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
|
||||
*/
|
||||
wholeTurns?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../app/e2e/support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../../app/e2e/support/helpers/launcher";
|
||||
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
|
||||
|
||||
// macOS desktop switches tabs with Cmd+Shift+brackets, like other Mac apps.
|
||||
// Web keeps Alt+Shift (the browser owns Cmd+Shift+brackets) and is covered in
|
||||
// the app browser suite. Windows/Linux desktop can't be emulated here: shortcut
|
||||
// OS detection falls back to the host user agent for non-darwin bridges.
|
||||
|
||||
async function installMacDesktopBridge(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const terminalTabs = (page: Page) =>
|
||||
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true });
|
||||
|
||||
async function openTwoTerminalTabs(page: Page) {
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs(page)).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs(page).nth(0);
|
||||
const second = terminalTabs(page).nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
return { first, second };
|
||||
}
|
||||
|
||||
test.describe("Previous/next tab shortcuts on desktop", () => {
|
||||
test("macOS desktop switches tabs with Cmd+Shift+[ and ], not Alt+Shift", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-mac-desktop-" });
|
||||
try {
|
||||
await installMacDesktopBridge(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const { first, second } = await openTwoTerminalTabs(page);
|
||||
|
||||
await page.keyboard.press("Meta+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Alt+Shift is the web and Windows/Linux binding and must not fire here.
|
||||
// Had it moved to the first tab, Cmd+Shift+[ would wrap back to the second.
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await page.keyboard.press("Meta+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.kind === "checkout-branch") {
|
||||
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
|
||||
const { agent, outcome } = event;
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
|
||||
event.timeline;
|
||||
const turn = await paseo.agents
|
||||
.ref(agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items: readonly AgentTimelineItem[] = turn.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item);
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
outcome.request;
|
||||
});
|
||||
|
||||
@@ -1527,7 +1527,9 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
|
||||
name: snapshot.name ?? snapshot.title,
|
||||
detail,
|
||||
};
|
||||
if (status === "failed") return { ...base, status, error: snapshot.output };
|
||||
if (status === "failed") {
|
||||
return { ...base, status, error: snapshot.output ?? { message: "Tool failed" } };
|
||||
}
|
||||
return { ...base, status, error: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
@@ -890,6 +891,72 @@ lines.on("line", (line) => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: "failed", output: undefined, error: { message: "Tool failed" } },
|
||||
{ status: "failed", output: null, error: { message: "Tool failed" } },
|
||||
{
|
||||
status: "failed",
|
||||
output: { message: "File not found" },
|
||||
error: { message: "File not found" },
|
||||
},
|
||||
{ status: "failed", output: "Permission denied", error: "Permission denied" },
|
||||
{ status: "completed", output: null, error: null },
|
||||
] as const)(
|
||||
"keeps a $status ACP tool with output $output valid on the history wire",
|
||||
async ({ status, output, error }) => {
|
||||
const harness = connectorHarness({
|
||||
promptUpdates: () => [
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "read-1",
|
||||
title: "Read file",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "read-1", status, rawOutput: output },
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "tool-history-acp",
|
||||
label: "Tool history ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
try {
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "Read a file" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === "completed",
|
||||
);
|
||||
const rows = events.flatMap((event) =>
|
||||
event.type === "timeline.item" && event.item.id === "read-1" ? [event.item] : [],
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(AgentTimelineItemPayloadSchema.safeParse(row).success).toBe(true);
|
||||
}
|
||||
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status, error });
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("serializes configuration mutations", async () => {
|
||||
let releaseMutation: (() => void) | null = null;
|
||||
const configOptions = [
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
|
||||
"agent.turn.after": {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
* The payload carries no history. Read the turn with
|
||||
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
|
||||
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
|
||||
* started before the read, or when `turnId` is `null`.
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
outcome: PluginTurnOutcome;
|
||||
};
|
||||
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
"agent.permission.after": {
|
||||
|
||||
@@ -1529,6 +1529,110 @@ test("does not replace a newer foreground turn after unavailable steer fallback
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent interrupting sends each start their own turn in order", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
|
||||
try {
|
||||
const prompts = ["first interrupt", "second interrupt", "third interrupt"];
|
||||
await Promise.all(
|
||||
prompts.map((prompt) =>
|
||||
startAgentRun(manager, agentId, prompt, logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "interrupt",
|
||||
runOptions: { clientMessageId: prompt },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.startPrompts.slice(-3)).toEqual(prompts);
|
||||
expect(manager.getAgent(agentId)?.activeForegroundTurnId).toBe(
|
||||
`active-turn-${session.startCount}`,
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Stop can cancel startup while an interrupting send waits behind it", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
class SlowStartSession extends SteeringTestSession {
|
||||
starting = false;
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "slow startup") {
|
||||
this.starting = true;
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
this.starting = false;
|
||||
}
|
||||
return super.startTurn(prompt);
|
||||
}
|
||||
override async interrupt(): Promise<void> {
|
||||
if (this.starting) release.resolve();
|
||||
await super.interrupt();
|
||||
}
|
||||
}
|
||||
const session = new SlowStartSession({ provider: "codex", cwd: process.cwd() });
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
|
||||
try {
|
||||
await startAgentRun(manager, agentId, "slow startup", logger, { replaceRunning: true });
|
||||
await entered.promise;
|
||||
const replacement = startAgentRun(manager, agentId, "queued replacement", logger, {
|
||||
replaceRunning: true,
|
||||
});
|
||||
void replacement.catch(() => undefined);
|
||||
const stop = manager.cancelAgentRun(agentId);
|
||||
await vi.waitFor(() => expect(session.interruptCount).toBe(3), { timeout: 1000 });
|
||||
await stop;
|
||||
await replacement;
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.startPrompts.slice(-2)).toEqual(["slow startup", "queued replacement"]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await manager.closeAgent(agentId);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an interrupting send replaces a turn whose start never finishes", async () => {
|
||||
class StalledStartSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "stalled start") {
|
||||
this.startPrompts.push(prompt);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new StalledStartSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stalled-start-"));
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
rescueTimeouts: { interruptSessionMs: 10 },
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
await startAgentRun(manager, agent.id, "stalled start", logger, { replaceRunning: true });
|
||||
await startAgentRun(manager, agent.id, "recovery", logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
expect(session.startPrompts).toEqual(["stalled start", "recovery"]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("steers a tracked autonomous turn without creating a replacement run", async () => {
|
||||
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
|
||||
@@ -13230,7 +13334,6 @@ test.each([
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -844,6 +844,7 @@ export class AgentManager {
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
|
||||
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly replacementMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly runs = new AgentRunState();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly idFactory: () => string;
|
||||
@@ -2112,6 +2113,7 @@ export class AgentManager {
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.replacementMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
);
|
||||
@@ -3414,31 +3416,8 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
const snapshot = this.requireAgent(agentId);
|
||||
if (
|
||||
snapshot.lifecycle !== "running" &&
|
||||
!snapshot.activeForegroundTurnId &&
|
||||
!this.runs.hasRun(agentId)
|
||||
) {
|
||||
return this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
try {
|
||||
await this.cancelAgentRunBefore(agentId, "replace");
|
||||
return this.streamAgent(agentId, prompt, options);
|
||||
} catch (error) {
|
||||
const latest = this.agents.get(agentId);
|
||||
if (latest) {
|
||||
latest.pendingReplacement = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.requireSessionAgent(agentId);
|
||||
return this.replaceRunInForegroundLane(agentId, prompt, options);
|
||||
}
|
||||
|
||||
async steerAgentRun(
|
||||
@@ -3562,18 +3541,26 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
|
||||
return this.runQueuedMutation(this.foregroundMutationTails, agentId, operation);
|
||||
}
|
||||
|
||||
private async runQueuedMutation<T>(
|
||||
tails: Map<string, Promise<void>>,
|
||||
agentId: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = tails.get(agentId) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(operation);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.foregroundMutationTails.set(agentId, tail);
|
||||
tails.set(agentId, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.foregroundMutationTails.get(agentId) === tail) {
|
||||
this.foregroundMutationTails.delete(agentId);
|
||||
if (tails.get(agentId) === tail) {
|
||||
tails.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3585,20 +3572,81 @@ export class AgentManager {
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
|
||||
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cancelAgentRunBefore(agent.id, "replace");
|
||||
return this.streamAgent(agent.id, prompt, options);
|
||||
} catch (error) {
|
||||
const latest = this.agents.get(agent.id);
|
||||
if (latest) {
|
||||
latest.pendingReplacement = false;
|
||||
/**
|
||||
* Cancels the current run and reserves its replacement in one foreground mutation, so a
|
||||
* concurrent replacement or Stop cannot land between the cancel and the new run's start.
|
||||
*/
|
||||
private async replaceRunInForegroundLane(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions | undefined,
|
||||
assertAdmission?: (agent: ActiveManagedAgent) => void,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
// Replacements queue in arrival order; each one holds the foreground lane only while
|
||||
// it cancels and reserves, so Stop is never stuck behind a replacement that is waiting.
|
||||
return this.runQueuedMutation(this.replacementMutationTails, agentId, () =>
|
||||
this.replaceRunWhenAdmitted(agentId, prompt, options, assertAdmission),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceRunWhenAdmitted(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions | undefined,
|
||||
assertAdmission?: (agent: ActiveManagedAgent) => void,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
let stalledStart: PendingForegroundRun | null = null;
|
||||
for (;;) {
|
||||
const admission = await this.runForegroundMutation(agentId, async () => {
|
||||
// Resolve the agent inside the lane: a reload while this send waited replaces it.
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// A run that is still starting has no turn to interrupt yet. Canceling it would drop
|
||||
// its prompt, so wait for it outside the lane, where Stop can still reach it. A start
|
||||
// that outlasts the rescue timeout is canceled like any other run.
|
||||
const starting = this.runs.getPendingRun(agent.id);
|
||||
if (starting && starting.start.status !== "started" && starting !== stalledStart) {
|
||||
return { status: "starting" as const, run: starting };
|
||||
}
|
||||
assertAdmission?.(agent);
|
||||
if (!this.hasInFlightRun(agent.id)) {
|
||||
return {
|
||||
status: "reserved" as const,
|
||||
iterator: this.streamAgent(agent.id, prompt, options),
|
||||
};
|
||||
}
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
try {
|
||||
const result = await this.cancelAgentRunNow(agent.id);
|
||||
if (result.status === "refused") {
|
||||
throw new AgentRunCancellationError(agent.id, "replace");
|
||||
}
|
||||
return {
|
||||
status: "reserved" as const,
|
||||
iterator: this.streamAgent(agent.id, prompt, options),
|
||||
};
|
||||
} catch (error) {
|
||||
agent.pendingReplacement = false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (admission.status === "reserved") {
|
||||
return admission.iterator;
|
||||
}
|
||||
const started = await this.waitWithTimeout({
|
||||
operation: Promise.race([admission.run.turnStartedPromise, admission.run.settledPromise]),
|
||||
timeoutMs: this.rescueTimeouts.interruptSessionMs,
|
||||
});
|
||||
if (started === "timed_out") {
|
||||
stalledStart = admission.run;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,17 @@ async function olderHost() {
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
function resolverFor(client: DaemonClient, callerBackground: boolean) {
|
||||
interface Caller {
|
||||
background: boolean;
|
||||
provider: string;
|
||||
currentModeId: string | null;
|
||||
}
|
||||
|
||||
function resolverFor(client: DaemonClient, caller: Caller | null) {
|
||||
return createHostResolver({
|
||||
serverId: "origin",
|
||||
callerAgentId: "caller",
|
||||
caller: () => ({ background: callerBackground }),
|
||||
callerAgentId: caller ? "caller" : undefined,
|
||||
caller: () => caller,
|
||||
local: {} as HostOperations,
|
||||
hosts: () => ({
|
||||
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
|
||||
@@ -120,6 +126,12 @@ function resolverFor(client: DaemonClient, callerBackground: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
const visibleCodexCaller: Caller = {
|
||||
background: false,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
};
|
||||
|
||||
const createArgs = {
|
||||
workspaceId: workspace.id,
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -129,7 +141,7 @@ const createArgs = {
|
||||
|
||||
test("a visible caller still creates agents on a host without background agents", async () => {
|
||||
const { client, socket } = await olderHost();
|
||||
const creating = resolverFor(client, false).use("srv_older", (host) =>
|
||||
const creating = resolverFor(client, visibleCodexCaller).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
);
|
||||
creating.catch(() => {});
|
||||
@@ -143,7 +155,37 @@ test("a background caller's remote child asks the host to update instead of turn
|
||||
const { client, socket } = await olderHost();
|
||||
|
||||
await expect(
|
||||
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
|
||||
resolverFor(client, { ...visibleCodexCaller, background: true }).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
|
||||
});
|
||||
|
||||
async function forwardedMode(caller: Caller | null, args: Record<string, unknown> = createArgs) {
|
||||
const { client, socket } = await olderHost();
|
||||
const creating = resolverFor(client, caller).use("srv_older", (host) =>
|
||||
host.createAgent(args, {} as never),
|
||||
);
|
||||
creating.catch(() => {});
|
||||
const request = (await socket.createRequest()) as { config: { modeId?: string } };
|
||||
return request.config.modeId;
|
||||
}
|
||||
|
||||
test("a remote child of the same provider starts in its caller's mode", async () => {
|
||||
expect(await forwardedMode(visibleCodexCaller)).toBe("full-access");
|
||||
});
|
||||
|
||||
test("an explicit mode wins over the caller's mode on a remote host", async () => {
|
||||
expect(
|
||||
await forwardedMode(visibleCodexCaller, { ...createArgs, settings: { modeId: "auto" } }),
|
||||
).toBe("auto");
|
||||
});
|
||||
|
||||
test("a remote child of another provider leaves its mode to the host", async () => {
|
||||
expect(await forwardedMode({ ...visibleCodexCaller, provider: "claude" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a remote create without a caller agent leaves its mode to the host", async () => {
|
||||
expect(await forwardedMode(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -203,8 +203,8 @@ export function createHostResolver(input: {
|
||||
local: HostOperations;
|
||||
hosts: () => ToolHosts;
|
||||
callerAgentId?: string;
|
||||
/** The destination can't see the caller, so its background defaults resolve before routing. */
|
||||
caller: () => BackgroundCaller | null;
|
||||
/** The destination can't see the caller, so its background and mode defaults resolve before routing. */
|
||||
caller: () => (BackgroundCaller & { provider: string; currentModeId: string | null }) | null;
|
||||
lockedCwd?: string;
|
||||
}) {
|
||||
return {
|
||||
@@ -222,6 +222,11 @@ export function createHostResolver(input: {
|
||||
async createAgent(args) {
|
||||
const parsed = remoteCreate.parse(args);
|
||||
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
|
||||
const caller = input.caller();
|
||||
// Same-provider children inherit the caller's mode, as they would on the same host.
|
||||
const modeId =
|
||||
parsed.settings?.modeId ??
|
||||
(caller?.provider === provider ? (caller.currentModeId ?? undefined) : undefined);
|
||||
const workspace = await client.findWorkspace(parsed.workspaceId);
|
||||
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
|
||||
const labels = {
|
||||
@@ -236,15 +241,13 @@ export function createHostResolver(input: {
|
||||
model,
|
||||
cwd: workspace.workspaceDirectory,
|
||||
title: parsed.title,
|
||||
modeId: parsed.settings?.modeId,
|
||||
modeId,
|
||||
thinkingOptionId: parsed.settings?.thinkingOptionId,
|
||||
featureValues: parsed.settings?.features,
|
||||
},
|
||||
workspaceId: parsed.workspaceId,
|
||||
// Older hosts reject any background with a workspaceId, so a visible create omits it.
|
||||
...(inheritAgentBackground(parsed.background, input.caller())
|
||||
? { background: true }
|
||||
: {}),
|
||||
...(inheritAgentBackground(parsed.background, caller) ? { background: true } : {}),
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ImportProviderSessionContext,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "./provider-session-import.js";
|
||||
|
||||
const SESSION_FILE = "/Users/dev/.omp/agent/sessions/project/2026-01-01_session-id.jsonl";
|
||||
const SESSION_ID = "session-id";
|
||||
|
||||
function config(cwd: string): AgentSessionConfig {
|
||||
return { provider: "omp", cwd };
|
||||
}
|
||||
|
||||
function context(cwd: string): ImportProviderSessionContext {
|
||||
return { config: config(cwd), storedConfig: config(cwd) };
|
||||
}
|
||||
|
||||
function fakeSession(described: AgentPersistenceHandle | null): AgentSession {
|
||||
return {
|
||||
async *streamHistory() {},
|
||||
describePersistence: () => described,
|
||||
} as unknown as AgentSession;
|
||||
}
|
||||
|
||||
describe("importSessionFromPersistence", () => {
|
||||
it("persists the resumed session identity instead of the listed file handle", async () => {
|
||||
const cwd = "/Users/dev/code/project";
|
||||
const imported = await importSessionFromPersistence({
|
||||
provider: "omp",
|
||||
request: { providerHandleId: SESSION_FILE, cwd },
|
||||
context: context(cwd),
|
||||
resumeSession: async () =>
|
||||
fakeSession({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_ID,
|
||||
nativeHandle: SESSION_FILE,
|
||||
metadata: { cwd, model: "resumed-model" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(imported.persistence).toMatchObject({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_ID,
|
||||
nativeHandle: SESSION_FILE,
|
||||
metadata: { cwd, provider: "omp", model: "resumed-model" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the requested handle when the provider describes no identity", async () => {
|
||||
const cwd = "/Users/dev/code/project";
|
||||
const imported = await importSessionFromPersistence({
|
||||
provider: "omp",
|
||||
request: { providerHandleId: SESSION_FILE, cwd },
|
||||
context: context(cwd),
|
||||
resumeSession: async () => fakeSession(null),
|
||||
});
|
||||
|
||||
expect(imported.persistence).toMatchObject({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_FILE,
|
||||
nativeHandle: SESSION_FILE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,12 +38,32 @@ export async function importSessionFromPersistence(input: {
|
||||
return {
|
||||
session,
|
||||
config: storedConfig,
|
||||
persistence,
|
||||
persistence: canonicalImportPersistence(persistence, session),
|
||||
timeline: history.timeline,
|
||||
providerSubagentEvents: history.providerSubagentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import handles identify a session by whatever the provider lists, which is not
|
||||
* always the session id: OMP lists session files. Persist the resumed session's own
|
||||
* identity instead, so records, dedupe and resume all agree, while the provider's
|
||||
* native handle still resolves the session.
|
||||
*/
|
||||
function canonicalImportPersistence(
|
||||
requested: AgentPersistenceHandle,
|
||||
session: ImportedProviderSession["session"],
|
||||
): AgentPersistenceHandle {
|
||||
const described = session.describePersistence();
|
||||
if (!described?.sessionId) return requested;
|
||||
return {
|
||||
...described,
|
||||
provider: requested.provider,
|
||||
nativeHandle: described.nativeHandle ?? requested.nativeHandle,
|
||||
metadata: { ...requested.metadata, ...described.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(
|
||||
provider: AgentProvider,
|
||||
input: ImportProviderSessionInput,
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Codex thread history", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
test("a page too big to send retries smaller pages, then warns at the item that cannot fit", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
@@ -163,11 +163,12 @@ describe("Codex thread history", () => {
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
...answers.map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Two", messageId: "user-2" },
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
@@ -176,6 +177,12 @@ describe("Codex thread history", () => {
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// Each oversized page retries the same cursor at half the size until it fits.
|
||||
expect(
|
||||
historyRequests(appServer)
|
||||
.filter((request) => request.method === "thread/items/list")
|
||||
.map((request) => (request.params as { limit: number }).limit),
|
||||
).toEqual([100, 100, 50, 25, 12, 12, 6, 6, 3, 3, 1, 1]);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
|
||||
@@ -124,15 +124,28 @@ async function* readPaginatedHistory(
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let limit = PAGE_SIZE;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await client.request("thread/items/list", {
|
||||
threadId,
|
||||
limit,
|
||||
sortDirection: "asc",
|
||||
cursor,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof CodexAppServerResponseTooLargeError)) throw error;
|
||||
// Image-heavy items can push a full page past the line cap; retry the same cursor smaller.
|
||||
if (limit > 1) {
|
||||
limit = Math.max(1, Math.floor(limit / 2));
|
||||
continue;
|
||||
}
|
||||
throw new CodexThreadHistoryTooLargeError(
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
const page = CodexItemsPageSchema.parse(response);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import { omitNullArgs } from "../tool-call-args.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -324,7 +325,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
if (parsed.success) {
|
||||
return {
|
||||
kind: toolName as SimpleToolKind,
|
||||
|
||||
@@ -77,6 +77,157 @@ function readPluginOptions(env: Record<string, string>): {
|
||||
}
|
||||
|
||||
describe("OpenCodeBridge", () => {
|
||||
test("discovers only the requested session's catalog and retains global discovery", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-discovery-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
|
||||
const catalog = createCatalog();
|
||||
bridge.setManifestCatalog(catalog);
|
||||
await bridge.start();
|
||||
const unbind = bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
|
||||
bridge.bindSession({ sessionId: "disabled", env: {} });
|
||||
try {
|
||||
const { baseUrl, token } = bridge.sharedV2Connection();
|
||||
async function discover(query: string) {
|
||||
const response = await fetch(`${baseUrl}/_internal/opencode/tools${query}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
const expected = {
|
||||
status: 200,
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
name: "echo_context",
|
||||
title: "Echo context",
|
||||
description: "Returns the supplied value.",
|
||||
inputSchema: serializePaseoToolInputParameters(catalog.getTool("echo_context")!),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(await discover("")).toEqual(expected);
|
||||
expect(await discover("?sessionID=bound")).toEqual(expected);
|
||||
expect(await discover("?sessionID=disabled")).toEqual({ status: 200, body: { tools: [] } });
|
||||
const missing = {
|
||||
status: 404,
|
||||
body: { error: "OpenCode session is not bound to a Paseo agent" },
|
||||
};
|
||||
expect(await discover("?sessionID=unknown")).toEqual(missing);
|
||||
expect(await discover("?sessionID=")).toEqual(missing);
|
||||
unbind();
|
||||
expect(await discover("?sessionID=bound")).toEqual(missing);
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("registers and executes session-only tools without exposing them to other sessions", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-session-tools-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
|
||||
const builtin = createCatalog();
|
||||
const pluginTool = {
|
||||
name: "plugin_action",
|
||||
description: "Run a session-specific plugin action.",
|
||||
inputSchema: { value: z.string() },
|
||||
};
|
||||
const tools = new Map([...builtin.tools, [pluginTool.name, pluginTool] as const]);
|
||||
const calls: unknown[] = [];
|
||||
const author: PaseoToolCatalog = {
|
||||
tools,
|
||||
getTool: (name) => tools.get(name),
|
||||
executeTool: async (name, input, context) => {
|
||||
if (name !== pluginTool.name) return builtin.executeTool(name, input, context);
|
||||
calls.push(input);
|
||||
return { content: [{ type: "text", text: "Plugin action complete" }] };
|
||||
},
|
||||
};
|
||||
bridge.setManifestCatalog(builtin);
|
||||
await bridge.start();
|
||||
bridge.bindSession({ sessionId: "author/one", env: {}, tools: author });
|
||||
bridge.bindSession({ sessionId: "other", env: {}, tools: builtin });
|
||||
bridge.bindSession({ sessionId: "disabled", env: {} });
|
||||
try {
|
||||
const registered = new Map<string, V2TestTool>();
|
||||
const hooks: Array<(input: V2TestContext) => Promise<void>> = [];
|
||||
const dispose = await setupBoundV2Plugin(
|
||||
bridge,
|
||||
{
|
||||
tool: {
|
||||
transform: async (transform) => {
|
||||
transform({
|
||||
add: (tool) => {
|
||||
registered.set(tool.name, tool);
|
||||
},
|
||||
});
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async ({ sessionID }) => ({
|
||||
parentID: sessionID === "child" ? "author/one" : undefined,
|
||||
}),
|
||||
hook: async (_name, hook) => {
|
||||
hooks.push(hook);
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
},
|
||||
["author/one", "other", "disabled"],
|
||||
);
|
||||
try {
|
||||
expect([...registered.keys()]).toEqual([
|
||||
"paseo_structured_output",
|
||||
"paseo_echo_context",
|
||||
`paseo_${pluginTool.name}`,
|
||||
]);
|
||||
const submit = registered.get(`paseo_${pluginTool.name}`)!;
|
||||
expect(submit.input).toEqual(serializePaseoToolInputParameters(pluginTool));
|
||||
const visibleTools = [
|
||||
["author/one", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
|
||||
["child", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
|
||||
["other", ["paseo_echo_context", "native"]],
|
||||
["disabled", ["native"]],
|
||||
["unrelated", ["native"]],
|
||||
] as const;
|
||||
for (const [sessionID, expected] of visibleTools) {
|
||||
const input = {
|
||||
sessionID,
|
||||
tools: Object.fromEntries([...registered.keys(), "native"].map((name) => [name, {}])),
|
||||
};
|
||||
for (const hook of hooks) await hook(input);
|
||||
expect(Object.keys(input.tools)).toEqual(expected);
|
||||
}
|
||||
await expect(
|
||||
submit.execute({ value: "revision" }, { sessionID: "author/one" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
|
||||
await expect(
|
||||
submit.execute({ value: "child revision" }, { sessionID: "child" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
|
||||
await expect(submit.execute({}, { sessionID: "other" })).rejects.toThrow(
|
||||
"Paseo tool not found",
|
||||
);
|
||||
await expect(submit.execute({}, { sessionID: "disabled" })).rejects.toThrow(
|
||||
"Paseo tools are disabled",
|
||||
);
|
||||
await expect(submit.execute({}, { sessionID: "unrelated" })).rejects.toThrow("not bound");
|
||||
expect(calls).toEqual([{ value: "revision" }, { value: "child revision" }]);
|
||||
await expect(
|
||||
registered
|
||||
.get("paseo_echo_context")!
|
||||
.execute({ value: "builtin" }, { sessionID: "other" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "builtin" }] });
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
|
||||
@@ -166,7 +166,9 @@ export class OpenCodeBridge {
|
||||
}
|
||||
const url = new URL(request.url ?? "/", this.requireBaseUrl());
|
||||
if (request.method === "GET" && url.pathname === `${INTERNAL_PREFIX}/tools`) {
|
||||
sendJson(response, 200, { tools: this.serializeManifest() });
|
||||
sendJson(response, 200, {
|
||||
tools: this.serializeManifest(url.searchParams.get("sessionID")),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,8 +230,15 @@ export class OpenCodeBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private serializeManifest(): Array<Record<string, unknown>> {
|
||||
const catalog = this.manifestCatalog;
|
||||
private serializeManifest(sessionId: string | null): Array<Record<string, unknown>> {
|
||||
let catalog: Pick<PaseoToolCatalog, "tools"> | null | undefined = this.manifestCatalog;
|
||||
if (sessionId !== null) {
|
||||
const binding = this.sessions.get(sessionId);
|
||||
if (!binding) {
|
||||
throw new OpenCodeBridgeClientError(404, "OpenCode session is not bound to a Paseo agent");
|
||||
}
|
||||
catalog = binding.tools;
|
||||
}
|
||||
if (!catalog) return [];
|
||||
return [...catalog.tools.values()].map((tool) => {
|
||||
const definition: Record<string, unknown> = {
|
||||
|
||||
@@ -38,7 +38,10 @@ export async function registerSharedBridge(context) {
|
||||
return null;
|
||||
}
|
||||
async function install(binding) {
|
||||
const manifest = await request(binding, "/tools");
|
||||
const manifest = await request(
|
||||
binding,
|
||||
`/tools?sessionID=${encodeURIComponent(binding.sessionID)}`,
|
||||
);
|
||||
for (const definition of manifest.tools) await installDefinition(definition);
|
||||
}
|
||||
async function installDefinition(definition) {
|
||||
|
||||
@@ -27,7 +27,8 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } };
|
||||
const tools = new Map([[tool.name, tool]]);
|
||||
bridge.setManifestCatalog({ tools });
|
||||
// Session-only tools must survive plugin reload without a global definition.
|
||||
bridge.setManifestCatalog({ tools: new Map() });
|
||||
await bridge.start();
|
||||
const unbind = bridge.bindSession({
|
||||
sessionId: "paseo",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { ProviderImageOutput } from "../provider-image-output.js";
|
||||
import { omitNullArgs } from "../tool-call-args.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -331,7 +332,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
if (parsed.success) {
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolArgs as parseOmpToolArgs } from "./omp/tool-call-detail.js";
|
||||
import { parseToolArgs as parsePiToolArgs } from "./pi/tool-call-mapper.js";
|
||||
|
||||
// Models send `null` for optional arguments (captured in #7011: read offset/limit), which the
|
||||
// runtimes treat as omitted. Rejecting them turned every such call into an unknown card.
|
||||
describe.each([
|
||||
["Pi", parsePiToolArgs],
|
||||
["OMP", parseOmpToolArgs],
|
||||
])("%s tool args with null optional values", (_provider, parseToolArgs) => {
|
||||
test.each([
|
||||
["read", { path: "./preview.png", offset: null, limit: null }, { path: "./preview.png" }],
|
||||
["bash", { command: "ls", timeout: null }, { command: "ls" }],
|
||||
])("parses %s as its own tool kind", (toolName, rawArgs, args) => {
|
||||
expect(parseToolArgs(toolName, rawArgs)).toEqual({ kind: toolName, toolName, args });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Models routinely send `null` for optional tool arguments (e.g. read offset/limit); the
|
||||
// runtimes treat that as omitted, so the mappers must too or the call degrades to an
|
||||
// unknown card that carries the raw result.
|
||||
export function omitNullArgs(rawArgs: unknown): unknown {
|
||||
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
|
||||
return rawArgs;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(rawArgs).filter(([, value]) => value !== null));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { DiffOpcode } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js";
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
@@ -137,6 +138,84 @@ test("cancelling a legacy subscriber during a shared revision does not revive it
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("legacy subscribers are not re-sent a snapshot that did not change", async () => {
|
||||
await withCheckout(async ({ cwd, manager }) => {
|
||||
await writeFile(join(cwd, ".gitignore"), "*.log\n");
|
||||
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
|
||||
let revisions = 0;
|
||||
const modern = await manager.streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
if (frame.opcode === DiffOpcode.End) revisions++;
|
||||
},
|
||||
);
|
||||
await modern.start();
|
||||
const updates: CheckoutDiffSnapshotPayload[] = [];
|
||||
const legacy = await manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) =>
|
||||
updates.push(snapshot),
|
||||
);
|
||||
try {
|
||||
expect(legacy.initial.files.map((file) => file.path)).toEqual([".gitignore", "x.ts"]);
|
||||
await waitForSubscriberCount(manager, 2);
|
||||
const before = revisions;
|
||||
await writeFile(join(cwd, "app.log"), "ignored\n");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
|
||||
await manager.scheduleRefreshForCwd(cwd);
|
||||
await expect.poll(() => revisions, { timeout: 10_000 }).toBeGreaterThan(before);
|
||||
expect(updates).toHaveLength(0);
|
||||
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
|
||||
await expect.poll(() => updates.length, { timeout: 10_000 }).toBe(1);
|
||||
expect(updates[0].files[1].hunks[0].lines).toContainEqual(
|
||||
expect.objectContaining({ type: "add", content: "const value = 2;" }),
|
||||
);
|
||||
} finally {
|
||||
await legacy.unsubscribe();
|
||||
await modern.release();
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("legacy All subscribers are not re-sent the snapshot when a commit only moves HEAD", async () => {
|
||||
await withCheckout(async ({ cwd, manager, git }) => {
|
||||
git("checkout", "-b", "feature");
|
||||
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
|
||||
const endedHeads: string[] = [];
|
||||
let beganHead = "";
|
||||
const modern = await manager.streams.subscribe(
|
||||
{
|
||||
cwd,
|
||||
base: { kind: "rev", rev: "main" },
|
||||
checkoutBase: { compare: { mode: "all", baseRef: "main" }, paseoHome: cwd },
|
||||
head: { kind: "worktree" },
|
||||
mergeBase: true,
|
||||
},
|
||||
async (frame) => {
|
||||
if (frame.opcode === DiffOpcode.Begin) beganHead = frame.headSha;
|
||||
if (frame.opcode === DiffOpcode.End) endedHeads.push(beganHead);
|
||||
},
|
||||
);
|
||||
await modern.start();
|
||||
const updates: CheckoutDiffSnapshotPayload[] = [];
|
||||
const legacy = await manager.subscribe(
|
||||
{ cwd, compare: { mode: "all", baseRef: "main" } },
|
||||
(snapshot) => updates.push(snapshot),
|
||||
);
|
||||
try {
|
||||
expect(legacy.initial.files.map((file) => file.path)).toEqual(["x.ts"]);
|
||||
await waitForSubscriberCount(manager, 2);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
|
||||
const head = git("rev-parse", "HEAD");
|
||||
await expect.poll(() => endedHeads.at(-1), { timeout: 10_000 }).toBe(head);
|
||||
expect(updates).toHaveLength(0);
|
||||
} finally {
|
||||
await legacy.unsubscribe();
|
||||
await modern.release();
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise<void> {
|
||||
await expect
|
||||
.poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 })
|
||||
|
||||
@@ -73,6 +73,7 @@ export class CheckoutDiffManager {
|
||||
resolveInitial = resolve;
|
||||
});
|
||||
let delivered = false;
|
||||
let deliveredFingerprint = "";
|
||||
let pending: DiffBegin | null = null;
|
||||
let files = new Map<string, ParsedDiffFile>();
|
||||
let bodies = new Map<string, DiffBody>();
|
||||
@@ -114,6 +115,11 @@ export class CheckoutDiffManager {
|
||||
payload = legacySnapshot(cwd, [...next.values()]);
|
||||
}
|
||||
pending = null;
|
||||
// The engine commits a revision per observed input change, including ones that only move the
|
||||
// resolved SHAs this snapshot omits. Old clients get a snapshot only when it differs.
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
if (fingerprint === deliveredFingerprint) return;
|
||||
deliveredFingerprint = fingerprint;
|
||||
if (delivered) listener(payload);
|
||||
else {
|
||||
delivered = true;
|
||||
|
||||
@@ -2127,6 +2127,57 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"a remote child without a mode starts in its same-provider caller's mode",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const a = await daemon({ mcpEnabled: true });
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
const destination = await api(b);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const directory = await online(origin.paseo);
|
||||
const workspace = await origin.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: a.staticDir },
|
||||
});
|
||||
const remoteWorkspace = await destination.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
});
|
||||
const parent = await workspace.agents.create({
|
||||
config: { provider: "claude/claude-test", modeId: "bypassPermissions" },
|
||||
title: "Mode parent",
|
||||
});
|
||||
const mcp = new Client({ name: "mode-parent", version: "1" });
|
||||
cleanup.push(() => mcp.close());
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const remoteMode = async (provider: string, settings?: { modeId: string }) => {
|
||||
const response = await mcp.callTool({
|
||||
name: "create_agent",
|
||||
arguments: {
|
||||
serverId: directory.hosts[0]!.serverId,
|
||||
workspaceId: remoteWorkspace.id,
|
||||
provider,
|
||||
title: "Mode child",
|
||||
initialPrompt: "Report your mode",
|
||||
settings,
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const childId = (response.structuredContent as { agentId: string }).agentId;
|
||||
return (await destination.client.fetchAgent(childId))?.agent.currentModeId;
|
||||
};
|
||||
|
||||
expect(await remoteMode("claude/claude-test")).toBe("bypassPermissions");
|
||||
expect(await remoteMode("claude/claude-test", { modeId: "default" })).toBe("default");
|
||||
// Another provider's modes are unknown here, so the destination applies its own default.
|
||||
expect(await remoteMode("codex/gpt-5.4")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"remote finish observation survives a permission checkpoint until resumed completion",
|
||||
{ timeout: 30000 },
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type CheckoutDiffCompare,
|
||||
} from "../../utils/checkout-git.js";
|
||||
import { lstat, readFile, readlink } from "node:fs/promises";
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
|
||||
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
|
||||
@@ -27,6 +26,16 @@ export interface ListedFile {
|
||||
identity: string;
|
||||
source: { kind: "tracked" } | { kind: "untracked"; size: number };
|
||||
}
|
||||
interface WorktreeInput {
|
||||
/** Comparison-side mode, or null when the file is added/untracked on this side. */
|
||||
oldMode: string | null;
|
||||
/** Comparison-side blob, or null when the file is added/untracked on this side. */
|
||||
oldBlob: string | null;
|
||||
/** A regular file whose bytes readGitInputs blob-hashes. */
|
||||
hashed: boolean;
|
||||
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
|
||||
literal: string | null;
|
||||
}
|
||||
interface ResolvedComparison {
|
||||
baseSha: string;
|
||||
headSha: string;
|
||||
@@ -195,20 +204,47 @@ async function readComparisonList(
|
||||
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
|
||||
},
|
||||
};
|
||||
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
|
||||
compare,
|
||||
args,
|
||||
options,
|
||||
);
|
||||
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
|
||||
const tracked = await readTrackedFiles(compare, args, options);
|
||||
const files = tracked.files;
|
||||
const worktreeInputs = tracked.worktreeInputs;
|
||||
if (compare.head.kind === "worktree") {
|
||||
const untracked = await listUntrackedFiles(options);
|
||||
files.push(...untracked.files);
|
||||
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
|
||||
}
|
||||
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
|
||||
for (const file of files) interpretationPaths.add(file.entry.path);
|
||||
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
|
||||
for (const file of files)
|
||||
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
|
||||
const hashedPaths = new Set(
|
||||
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
|
||||
);
|
||||
const literalContent = new Map(
|
||||
[...worktreeInputs].flatMap(([path, input]) =>
|
||||
input.literal === null ? [] : [[path, input.literal] as const],
|
||||
),
|
||||
);
|
||||
const gitInputs = await readGitInputs({
|
||||
paths: [...tracked.interpretationPaths].sort(),
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input = worktreeInputs.get(file.entry.path);
|
||||
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
|
||||
file.identity = JSON.stringify([
|
||||
input.oldMode,
|
||||
input.oldBlob,
|
||||
gitInputs.identities.get(file.entry.path) ?? null,
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
file.identity += JSON.stringify([
|
||||
gitInputs.identities.get(file.entry.path),
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
|
||||
]);
|
||||
}
|
||||
const metrics = { highlightCalls: 0 };
|
||||
const patches = new Patches(args, options, files);
|
||||
const sources = new HighlightSources({
|
||||
@@ -237,7 +273,15 @@ async function readComparisonList(
|
||||
const result =
|
||||
file.source.kind === "untracked"
|
||||
? await runGitCommand(
|
||||
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-textconv",
|
||||
...GIT_PATCH_FORMAT_ARGS,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file.entry.path,
|
||||
],
|
||||
{
|
||||
...options,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -293,7 +337,11 @@ async function readTrackedFiles(
|
||||
compare: Compare,
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
|
||||
): Promise<{
|
||||
files: ListedFile[];
|
||||
worktreeInputs: Map<string, WorktreeInput>;
|
||||
interpretationPaths: Set<string>;
|
||||
}> {
|
||||
const [raw, stat] = await Promise.all([
|
||||
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
|
||||
runGitCommand([...args, "--numstat", "-z"], options),
|
||||
@@ -303,7 +351,7 @@ async function readTrackedFiles(
|
||||
const stats = parseStats(stat.stdout);
|
||||
const fields = raw.stdout.split("\0");
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeFiles = new Set<string>();
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const interpretationPaths = new Set<string>();
|
||||
for (let index = 0; index < fields.length - 1; index++) {
|
||||
const header = fields[index].split(" ");
|
||||
@@ -325,10 +373,25 @@ async function readTrackedFiles(
|
||||
else if (code === "D") change = "deleted";
|
||||
else if (oldPath !== null) change = "renamed";
|
||||
let identity = header.join(" ");
|
||||
if (compare.head.kind === "worktree" && code !== "D") {
|
||||
const worktree = await readWorktreeIdentity(path, header, options);
|
||||
identity += `:${worktree.identity}`;
|
||||
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input: WorktreeInput = {
|
||||
oldMode: code === "A" ? null : header[0],
|
||||
oldBlob: code === "A" ? null : absentBlob(header[2]),
|
||||
hashed: false,
|
||||
literal: null,
|
||||
};
|
||||
if (code !== "D") {
|
||||
const worktree = await readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text: !counts.binary,
|
||||
options,
|
||||
});
|
||||
input.hashed = worktree.hashed;
|
||||
input.literal = worktree.literal;
|
||||
}
|
||||
worktreeInputs.set(path, input);
|
||||
identity = "";
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -345,24 +408,32 @@ async function readTrackedFiles(
|
||||
source: { kind: "tracked" },
|
||||
});
|
||||
}
|
||||
return { files, worktreeFiles, interpretationPaths };
|
||||
return { files, worktreeInputs, interpretationPaths };
|
||||
}
|
||||
|
||||
async function readWorktreeIdentity(
|
||||
path: string,
|
||||
header: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ identity: string; regular: boolean }> {
|
||||
const value = await lstat(resolve(options.cwd, path), { bigint: true });
|
||||
let identity = fileStatIdentity(value);
|
||||
async function readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text,
|
||||
options,
|
||||
}: {
|
||||
path: string;
|
||||
header: string[];
|
||||
text: boolean;
|
||||
options: GitCommandOptions;
|
||||
}): Promise<{ hashed: boolean; literal: string | null }> {
|
||||
const absolutePath = resolve(options.cwd, path);
|
||||
const value = await lstat(absolutePath, { bigint: true });
|
||||
if (value.isSymbolicLink())
|
||||
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
|
||||
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
|
||||
const submodule = await runGitCommand(
|
||||
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
|
||||
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
);
|
||||
identity += `:${submodule.stdout}`;
|
||||
return { hashed: false, literal: `submodule:${submodule.stdout}` };
|
||||
}
|
||||
return { identity, regular: value.isFile() };
|
||||
return { hashed: value.isFile() && text, literal: null };
|
||||
}
|
||||
|
||||
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
|
||||
@@ -385,9 +456,12 @@ async function readUntrackedText(
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
|
||||
async function listUntrackedFiles(
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
|
||||
const cwd = options.cwd;
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const untracked = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
options,
|
||||
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
binary || content.length === 0
|
||||
? 0
|
||||
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
|
||||
worktreeInputs.set(path, {
|
||||
oldMode: null,
|
||||
oldBlob: null,
|
||||
hashed: !symlink && !binary && !omitted,
|
||||
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
|
||||
});
|
||||
files.push({
|
||||
entry: {
|
||||
path,
|
||||
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
deletions: 0,
|
||||
body: !binary,
|
||||
},
|
||||
identity: fileStatIdentity(value),
|
||||
identity: "",
|
||||
source: { kind: "untracked", size: Number(value.size) },
|
||||
});
|
||||
}
|
||||
return files;
|
||||
return { files, worktreeInputs };
|
||||
}
|
||||
|
||||
function fileStatIdentity(value: BigIntStats): string {
|
||||
// Timestamp-preserving copies still change file status or replace the inode.
|
||||
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
|
||||
function absentBlob(value: string): string | null {
|
||||
return /^0+$/.test(value) ? null : value;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ interface Interpretation {
|
||||
|
||||
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
|
||||
// for effective attributes/config and filtered blob identities without writing objects.
|
||||
export async function readGitInputs(
|
||||
paths: string[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
) {
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
interface ReadGitInputsOptions {
|
||||
paths: string[];
|
||||
hashedPaths: ReadonlySet<string>;
|
||||
options: GitCommandOptions;
|
||||
literalContent?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export async function readGitInputs({
|
||||
paths,
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent = new Map(),
|
||||
}: ReadGitInputsOptions) {
|
||||
const regular = paths.filter((path) => hashedPaths.has(path));
|
||||
const [interpretation, blobs] = await Promise.all([
|
||||
readInterpretation(paths, options),
|
||||
regular.length
|
||||
@@ -34,7 +42,7 @@ export async function readGitInputs(
|
||||
fingerprint([
|
||||
interpretation.config,
|
||||
interpretation.attributes.get(path) ?? [],
|
||||
blobByPath.get(path) ?? null,
|
||||
blobByPath.get(path) ?? literalContent.get(path) ?? null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user