mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 11:55:03 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3c9109a0 | ||
|
|
4cc3b69cbe | ||
|
|
543dc27244 |
@@ -20,16 +20,9 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
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
|
||||
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
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
|
||||
@@ -150,15 +150,6 @@ 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.
|
||||
|
||||
+1
-2
@@ -248,7 +248,6 @@ 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`
|
||||
@@ -489,7 +488,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, 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`.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -644,22 +644,6 @@ 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", " ");
|
||||
@@ -745,25 +729,13 @@ 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 list, quote, and code blocks", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render structured text.",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -788,9 +760,6 @@ 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 { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,12 +918,11 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
// 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 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);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
tapContextWindowMeter,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -509,6 +510,24 @@ test("context details stay open while the chat streams", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a wide touch screen", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
// A tap can request the agent's usage more than once.
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const details = await tapContextWindowMeter(page);
|
||||
// Past the card's close grace. A touch sends no pointer movement after the tap.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(details).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -127,42 +127,8 @@ 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",
|
||||
@@ -193,9 +159,7 @@ 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.");
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
watchWorkspaceHoverCard,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -543,6 +544,29 @@ test.describe("Sidebar workspace list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
|
||||
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
|
||||
test.describe("Sidebar workspace rows on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
|
||||
|
||||
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
const hoverCard = await watchWorkspaceHoverCard(page);
|
||||
await row.tap();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
// A card that opens during the tap is what makes iPad Safari drop the tap.
|
||||
expect(await hoverCard.hasOpened()).toBe(false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -85,20 +85,6 @@ 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.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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,14 +505,10 @@ 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 expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -41,6 +41,14 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Taps the meter on a touch screen and returns its details once they are on screen. */
|
||||
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
|
||||
@@ -142,8 +142,6 @@ 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") {
|
||||
@@ -234,9 +232,7 @@ 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 &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -280,7 +276,6 @@ 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(),
|
||||
|
||||
@@ -132,6 +132,31 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether a workspace hover card mounts at any point from now on, including one that
|
||||
* closes again before an assertion could see it.
|
||||
*/
|
||||
export async function watchWorkspaceHoverCard(
|
||||
page: Page,
|
||||
): Promise<{ hasOpened: () => Promise<boolean> }> {
|
||||
await page.evaluate(() => {
|
||||
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
|
||||
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
|
||||
target.__workspaceHoverCardOpened = isOpen();
|
||||
new MutationObserver(() => {
|
||||
if (isOpen()) target.__workspaceHoverCardOpened = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
return {
|
||||
hasOpened: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __workspaceHoverCardOpened?: boolean })
|
||||
.__workspaceHoverCardOpened === true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -20,42 +19,6 @@ 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
|
||||
@@ -99,14 +62,6 @@ 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 = []
|
||||
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -221,18 +172,6 @@ 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,56 +759,6 @@ 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,8 +81,7 @@ turndown.addRule("compactListItem", {
|
||||
const item = content
|
||||
.replace(/^\n+/, "")
|
||||
.replace(/\n+$/, "\n")
|
||||
// Every non-empty line, so indented code lines stay inside the item.
|
||||
.replace(/\n(?=[^\n])/g, "\n ");
|
||||
.replace(/\n(?=\S)/g, "\n ");
|
||||
const parent = node.parentElement;
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
@@ -360,9 +359,6 @@ 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") &&
|
||||
@@ -390,11 +386,6 @@ 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 &&
|
||||
|
||||
@@ -106,76 +106,29 @@ function ProviderSettingsAction({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
{children}
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
|
||||
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</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,
|
||||
);
|
||||
@@ -684,14 +637,24 @@ function ModelRowProfileAction({
|
||||
[visible],
|
||||
);
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1727,10 +1690,8 @@ 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],
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Dimensions,
|
||||
View,
|
||||
type PointerEvent as ViewPointerEvent,
|
||||
type Role,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -35,7 +42,7 @@ interface HoverCardContextValue {
|
||||
setTriggerRef: (node: View | null) => void;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
openNow: () => void;
|
||||
pointerEntered: (event: ViewPointerEvent) => void;
|
||||
scheduleClose: () => void;
|
||||
}
|
||||
|
||||
@@ -55,21 +62,34 @@ const HoverCardContext = createContext<HoverCardContextValue | null>(null);
|
||||
*/
|
||||
export function HoverCard({
|
||||
disabled = false,
|
||||
ignoreTouch = false,
|
||||
children,
|
||||
}: {
|
||||
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The trigger handles a tap itself, so a touch does not open the card: neither the touch's
|
||||
* pointerenter nor the focus the tap leaves on the trigger. Opening during a tap changes the
|
||||
* page under the finger, and iPad Safari then drops the tap.
|
||||
*/
|
||||
ignoreTouch?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!isWeb) return children;
|
||||
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
|
||||
return (
|
||||
<WebHoverCard disabled={disabled} ignoreTouch={ignoreTouch}>
|
||||
{children}
|
||||
</WebHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WebHoverCard({
|
||||
disabled,
|
||||
ignoreTouch,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
ignoreTouch: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const layer = useOverlayLayer("floating");
|
||||
@@ -79,6 +99,8 @@ function WebHoverCard({
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||
const keyboardFocusedRef = useRef(false);
|
||||
// Whether the latest press was a touch, so with ignoreTouch the focus that tap leaves is ignored.
|
||||
const touchPressedRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -125,6 +147,13 @@ function WebHoverCard({
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
const pointerEntered = useCallback(
|
||||
(event: ViewPointerEvent) => {
|
||||
if (!(ignoreTouch && event.nativeEvent.pointerType === "touch")) openNow();
|
||||
},
|
||||
[ignoreTouch, openNow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
@@ -191,7 +220,7 @@ function WebHoverCard({
|
||||
const target = event.target as Element;
|
||||
keyboardFocusedRef.current = target.matches(":focus-visible");
|
||||
if (trigger.contains(target)) {
|
||||
openNow();
|
||||
if (!(ignoreTouch && touchPressedRef.current)) openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
@@ -208,6 +237,12 @@ function WebHoverCard({
|
||||
scheduleClose();
|
||||
}
|
||||
};
|
||||
const pointerPressed = (event: PointerEvent) => {
|
||||
touchPressedRef.current = event.pointerType === "touch";
|
||||
};
|
||||
const keyPressedAnywhere = () => {
|
||||
touchPressedRef.current = false;
|
||||
};
|
||||
const activateClosedTrigger = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!open &&
|
||||
@@ -221,19 +256,23 @@ function WebHoverCard({
|
||||
const scrolled = (event: Event) => {
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", pointerPressed, true);
|
||||
document.addEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
trigger.addEventListener("keydown", activateClosedTrigger);
|
||||
document.addEventListener("scroll", scrolled, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", pointerPressed, true);
|
||||
document.removeEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.removeEventListener("focusin", focusEntered);
|
||||
document.removeEventListener("focusout", focusLeft);
|
||||
trigger.removeEventListener("keydown", activateClosedTrigger);
|
||||
document.removeEventListener("scroll", scrolled, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
|
||||
}, [clearGraceTimer, ignoreTouch, keyPressed, open, openNow, scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
@@ -256,8 +295,16 @@ function WebHoverCard({
|
||||
useEffect(() => clearGraceTimer, [clearGraceTimer]);
|
||||
|
||||
const value = useMemo<HoverCardContextValue>(
|
||||
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
|
||||
[open, layer, setTriggerRef, openNow, scheduleClose],
|
||||
() => ({
|
||||
open,
|
||||
layer,
|
||||
setTriggerRef,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
pointerEntered,
|
||||
scheduleClose,
|
||||
}),
|
||||
[open, layer, setTriggerRef, pointerEntered, scheduleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -285,7 +332,7 @@ export function HoverCardTrigger({
|
||||
accessibilityRole={focusable ? "button" : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
collapsable={false}
|
||||
onPointerEnter={ctx.openNow}
|
||||
onPointerEnter={ctx.pointerEntered}
|
||||
onPointerLeave={ctx.scheduleClose}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -88,18 +88,6 @@ 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,11 +120,6 @@ 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") {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard disabled={isDragging || disabled}>
|
||||
<HoverCard disabled={isDragging || disabled} ignoreTouch>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="right"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 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,7 +47,6 @@ 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;
|
||||
@@ -165,7 +164,8 @@ async function submitDraftCreateRequest(input: {
|
||||
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const imagesData = await encodeImages(images);
|
||||
const request = {
|
||||
const options = {
|
||||
idempotencyKey: input.draftId,
|
||||
config,
|
||||
workspaceId,
|
||||
initialPrompt: text,
|
||||
@@ -174,10 +174,8 @@ async function submitDraftCreateRequest(input: {
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
const result = await client.createAgent({
|
||||
...request,
|
||||
idempotencyKey: draftCreationKey(input.draftId, request),
|
||||
});
|
||||
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
|
||||
const result = creation ? await creation.retry(options) : await client.createAgent(options);
|
||||
|
||||
return {
|
||||
agentId: result.id,
|
||||
|
||||
@@ -4,32 +4,20 @@ 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 () => {
|
||||
if (surfaces.delete(token)) notify();
|
||||
surfaces.delete(token);
|
||||
};
|
||||
},
|
||||
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
|
||||
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "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,7 +10,6 @@ export interface KeyboardSourceHandlers {
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
subscribeCommandCenter(listener: () => void): () => void;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
@@ -24,6 +23,5 @@ export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -366,13 +366,7 @@ describe("Electron guest source through shared resolution", () => {
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
@@ -19,9 +19,6 @@ 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,10 +1,9 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import type { 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,
|
||||
@@ -20,9 +19,6 @@ 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,19 +3,12 @@ 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,19 +1,11 @@
|
||||
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,
|
||||
});
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return hardwareKeyboard.attach(handlers);
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
|
||||
hardwareKeyboard.publishPolicy(input);
|
||||
}
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -25,7 +26,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy(input)
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,31 @@
|
||||
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import {
|
||||
nativeKeyboardInput,
|
||||
type KeyboardPolicyInput,
|
||||
type KeyboardSourceHandlers,
|
||||
} from "./contract";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
}
|
||||
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
}
|
||||
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([]);
|
||||
};
|
||||
},
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,20 +226,6 @@ 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 },
|
||||
@@ -493,11 +479,6 @@ 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 },
|
||||
@@ -1325,17 +1306,6 @@ 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,37 +715,11 @@ 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: { mac: false, commandCenter: false },
|
||||
when: { commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
@@ -757,31 +731,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+]",
|
||||
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 },
|
||||
when: { commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
|
||||
@@ -25,7 +25,6 @@ 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 { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
@@ -148,22 +148,23 @@ describe("hardware source adapter", () => {
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = createHardwareKeyboardSource({
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts() {},
|
||||
}).attach({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
@@ -179,94 +180,4 @@ 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, usesDesktopBindings, enabled });
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
@@ -265,10 +265,6 @@ 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,7 +134,6 @@ describe("DOM source through the shared resolver", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
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";
|
||||
@@ -123,6 +126,7 @@ 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;
|
||||
@@ -239,6 +243,7 @@ interface SubmitDraftInput {
|
||||
clearConsumedDraft: () => void;
|
||||
agentCreation?: {
|
||||
result: Promise<AgentSnapshotPayload>;
|
||||
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
|
||||
};
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
@@ -466,13 +471,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
images: images?.length ? images : undefined,
|
||||
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
|
||||
};
|
||||
const execute = async (): Promise<AgentSnapshotPayload> => {
|
||||
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
|
||||
const { agent } = await ensureWorkspace({
|
||||
cwd,
|
||||
prompt: text,
|
||||
attachments: workspaceNamingAttachments,
|
||||
withInitialAgent: true,
|
||||
agent: initialAgent,
|
||||
agent: requestedAgent,
|
||||
onEvent: (snapshot) => {
|
||||
if (!snapshot.workspace || navigated) return;
|
||||
navigated = true;
|
||||
@@ -511,8 +516,11 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
if (!agent) throw new Error("Workspace creation returned no agent");
|
||||
return agent;
|
||||
};
|
||||
// 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()) };
|
||||
const agentCreation = {
|
||||
result: Promise.resolve().then(() => execute()),
|
||||
retry: (request: CreateAgentRequestOptions) =>
|
||||
execute(retriedAgentRequest(initialAgent, request, cwd)),
|
||||
};
|
||||
await agentCreation.result;
|
||||
if (outcome === "background") clearConsumedDraft();
|
||||
return outcome;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -45,3 +45,27 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -8,6 +9,7 @@ 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;
|
||||
@@ -33,6 +35,7 @@ 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;
|
||||
@@ -66,8 +69,12 @@ 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,
|
||||
@@ -87,8 +94,10 @@ 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 };
|
||||
return { setupByDraftId, creationByDraftId };
|
||||
});
|
||||
},
|
||||
consumePending: (input) => {
|
||||
|
||||
@@ -115,26 +115,6 @@ 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`;
|
||||
@@ -844,23 +824,6 @@ 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,7 +124,6 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeFilePasteListener: () => void;
|
||||
removeUserInputListeners: () => void;
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
@@ -158,10 +157,6 @@ 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();
|
||||
|
||||
@@ -318,8 +313,6 @@ 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[] = [];
|
||||
@@ -746,17 +739,8 @@ 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 && !this.isDispatchingUserInput()) {
|
||||
if (this.suppressInput) {
|
||||
return;
|
||||
}
|
||||
this.callbacks.onInput?.(data);
|
||||
@@ -860,12 +844,6 @@ 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: () => {
|
||||
@@ -895,7 +873,6 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeFilePasteListener();
|
||||
disposables.removeUserInputListeners();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeWebglAddon();
|
||||
disposables.disposeTerminal();
|
||||
@@ -936,10 +913,6 @@ 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;
|
||||
|
||||
@@ -306,11 +306,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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,7 +393,6 @@ 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") {
|
||||
@@ -427,17 +426,8 @@ describe("lifecycle hook SDK types", () => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
|
||||
const { agent, outcome } = event;
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentToolDefinition } from "./agent-tools.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -103,13 +104,12 @@ export interface PluginHookEvents {
|
||||
"agent.turn.after": {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
};
|
||||
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
"agent.permission.after": {
|
||||
|
||||
@@ -1529,110 +1529,6 @@ 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-"));
|
||||
@@ -13334,6 +13230,7 @@ test.each([
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -844,7 +844,6 @@ 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;
|
||||
@@ -2113,7 +2112,6 @@ 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")
|
||||
);
|
||||
@@ -3416,8 +3414,31 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
this.requireSessionAgent(agentId);
|
||||
return this.replaceRunInForegroundLane(agentId, prompt, options);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async steerAgentRun(
|
||||
@@ -3541,26 +3562,18 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
||||
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 previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(operation);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
tails.set(agentId, tail);
|
||||
this.foregroundMutationTails.set(agentId, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (tails.get(agentId) === tail) {
|
||||
tails.delete(agentId);
|
||||
if (this.foregroundMutationTails.get(agentId) === tail) {
|
||||
this.foregroundMutationTails.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3572,81 +3585,20 @@ export class AgentManager {
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
||||
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
|
||||
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
|
||||
);
|
||||
}
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,17 +107,11 @@ async function olderHost() {
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
interface Caller {
|
||||
background: boolean;
|
||||
provider: string;
|
||||
currentModeId: string | null;
|
||||
}
|
||||
|
||||
function resolverFor(client: DaemonClient, caller: Caller | null) {
|
||||
function resolverFor(client: DaemonClient, callerBackground: boolean) {
|
||||
return createHostResolver({
|
||||
serverId: "origin",
|
||||
callerAgentId: caller ? "caller" : undefined,
|
||||
caller: () => caller,
|
||||
callerAgentId: "caller",
|
||||
caller: () => ({ background: callerBackground }),
|
||||
local: {} as HostOperations,
|
||||
hosts: () => ({
|
||||
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
|
||||
@@ -126,12 +120,6 @@ function resolverFor(client: DaemonClient, caller: Caller | null) {
|
||||
});
|
||||
}
|
||||
|
||||
const visibleCodexCaller: Caller = {
|
||||
background: false,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
};
|
||||
|
||||
const createArgs = {
|
||||
workspaceId: workspace.id,
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -141,7 +129,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, visibleCodexCaller).use("srv_older", (host) =>
|
||||
const creating = resolverFor(client, false).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
);
|
||||
creating.catch(() => {});
|
||||
@@ -155,37 +143,7 @@ test("a background caller's remote child asks the host to update instead of turn
|
||||
const { client, socket } = await olderHost();
|
||||
|
||||
await expect(
|
||||
resolverFor(client, { ...visibleCodexCaller, background: true }).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
),
|
||||
resolverFor(client, 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 and mode defaults resolve before routing. */
|
||||
caller: () => (BackgroundCaller & { provider: string; currentModeId: string | null }) | null;
|
||||
/** The destination can't see the caller, so its background defaults resolve before routing. */
|
||||
caller: () => BackgroundCaller | null;
|
||||
lockedCwd?: string;
|
||||
}) {
|
||||
return {
|
||||
@@ -222,11 +222,6 @@ 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 = {
|
||||
@@ -241,13 +236,15 @@ export function createHostResolver(input: {
|
||||
model,
|
||||
cwd: workspace.workspaceDirectory,
|
||||
title: parsed.title,
|
||||
modeId,
|
||||
modeId: parsed.settings?.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, caller) ? { background: true } : {}),
|
||||
...(inheritAgentBackground(parsed.background, input.caller())
|
||||
? { background: true }
|
||||
: {}),
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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,32 +38,12 @@ export async function importSessionFromPersistence(input: {
|
||||
return {
|
||||
session,
|
||||
config: storedConfig,
|
||||
persistence: canonicalImportPersistence(persistence, session),
|
||||
persistence,
|
||||
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 retries smaller pages, then warns at the item that cannot fit", async () => {
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
@@ -163,12 +163,11 @@ describe("Codex thread history", () => {
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.map((answer) => ({
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Two", messageId: "user-2" },
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
@@ -177,12 +176,6 @@ 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,28 +124,15 @@ async function* readPaginatedHistory(
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let limit = PAGE_SIZE;
|
||||
for (;;) {
|
||||
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(
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
"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,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import { omitNullArgs } from "../tool-call-args.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -325,7 +324,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return {
|
||||
kind: toolName as SimpleToolKind,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -332,7 +331,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
// 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,7 +4,6 @@ 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";
|
||||
@@ -138,84 +137,6 @@ 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,7 +73,6 @@ 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>();
|
||||
@@ -115,11 +114,6 @@ 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,57 +2127,6 @@ 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,6 +12,7 @@ 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";
|
||||
@@ -26,16 +27,6 @@ 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;
|
||||
@@ -204,47 +195,20 @@ async function readComparisonList(
|
||||
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
|
||||
},
|
||||
};
|
||||
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) 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,
|
||||
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
|
||||
compare,
|
||||
args,
|
||||
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;
|
||||
}
|
||||
);
|
||||
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
|
||||
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)
|
||||
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({
|
||||
@@ -273,15 +237,7 @@ async function readComparisonList(
|
||||
const result =
|
||||
file.source.kind === "untracked"
|
||||
? await runGitCommand(
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-textconv",
|
||||
...GIT_PATCH_FORMAT_ARGS,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file.entry.path,
|
||||
],
|
||||
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
|
||||
{
|
||||
...options,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -337,11 +293,7 @@ async function readTrackedFiles(
|
||||
compare: Compare,
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{
|
||||
files: ListedFile[];
|
||||
worktreeInputs: Map<string, WorktreeInput>;
|
||||
interpretationPaths: Set<string>;
|
||||
}> {
|
||||
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; 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),
|
||||
@@ -351,7 +303,7 @@ async function readTrackedFiles(
|
||||
const stats = parseStats(stat.stdout);
|
||||
const fields = raw.stdout.split("\0");
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const worktreeFiles = new Set<string>();
|
||||
const interpretationPaths = new Set<string>();
|
||||
for (let index = 0; index < fields.length - 1; index++) {
|
||||
const header = fields[index].split(" ");
|
||||
@@ -373,25 +325,10 @@ async function readTrackedFiles(
|
||||
else if (code === "D") change = "deleted";
|
||||
else if (oldPath !== null) change = "renamed";
|
||||
let identity = header.join(" ");
|
||||
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 = "";
|
||||
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);
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -408,32 +345,24 @@ async function readTrackedFiles(
|
||||
source: { kind: "tracked" },
|
||||
});
|
||||
}
|
||||
return { files, worktreeInputs, interpretationPaths };
|
||||
return { files, worktreeFiles, interpretationPaths };
|
||||
}
|
||||
|
||||
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)}` };
|
||||
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);
|
||||
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
|
||||
const submodule = await runGitCommand(
|
||||
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
|
||||
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
);
|
||||
return { hashed: false, literal: `submodule:${submodule.stdout}` };
|
||||
identity += `:${submodule.stdout}`;
|
||||
}
|
||||
return { hashed: value.isFile() && text, literal: null };
|
||||
return { identity, regular: value.isFile() };
|
||||
}
|
||||
|
||||
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
|
||||
@@ -456,12 +385,9 @@ async function readUntrackedText(
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function listUntrackedFiles(
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
|
||||
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
|
||||
const cwd = options.cwd;
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const untracked = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
options,
|
||||
@@ -482,12 +408,6 @@ async function listUntrackedFiles(
|
||||
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,
|
||||
@@ -498,13 +418,14 @@ async function listUntrackedFiles(
|
||||
deletions: 0,
|
||||
body: !binary,
|
||||
},
|
||||
identity: "",
|
||||
identity: fileStatIdentity(value),
|
||||
source: { kind: "untracked", size: Number(value.size) },
|
||||
});
|
||||
}
|
||||
return { files, worktreeInputs };
|
||||
return files;
|
||||
}
|
||||
|
||||
function absentBlob(value: string): string | null {
|
||||
return /^0+$/.test(value) ? null : value;
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -9,20 +9,12 @@ 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.
|
||||
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));
|
||||
export async function readGitInputs(
|
||||
paths: string[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
) {
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
const [interpretation, blobs] = await Promise.all([
|
||||
readInterpretation(paths, options),
|
||||
regular.length
|
||||
@@ -42,7 +34,7 @@ export async function readGitInputs({
|
||||
fingerprint([
|
||||
interpretation.config,
|
||||
interpretation.attributes.get(path) ?? [],
|
||||
blobByPath.get(path) ?? literalContent.get(path) ?? null,
|
||||
blobByPath.get(path) ?? null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -530,222 +530,6 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-only changes reuse cached bodies and send no body frames", async () => {
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "file.txt");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
await writeFile(path, "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(path, "changed\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
|
||||
frames.length = 0;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
|
||||
const timestamp = new Date(Date.now() + 1000 * (index + 1));
|
||||
await utimes(path, timestamp, timestamp);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
|
||||
timeout: 5000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(ended + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
|
||||
expect(beginnings.length).toBeGreaterThanOrEqual(3);
|
||||
for (const begin of beginnings) {
|
||||
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("staging unchanged worktree content reuses its cached body", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
|
||||
frames.length = 0;
|
||||
git("add", "-A");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
|
||||
entries: [{ path: "staged.ts", change: "added", body: false }],
|
||||
});
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"untracked bodies use the same patch conversion as tracked files",
|
||||
async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
|
||||
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "x.dat"), "hello\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
const initial = frames
|
||||
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
|
||||
.find((file) => file.path === "x.dat");
|
||||
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
|
||||
type: "add",
|
||||
content: "hello",
|
||||
});
|
||||
frames.length = 0;
|
||||
git("add", "x.dat");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"comparison-side file type changes refresh cached bodies",
|
||||
async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "x");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(path, "target");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
|
||||
git("branch", "compare");
|
||||
await rm(path);
|
||||
await symlink("target", path);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
|
||||
const moved = git("rev-parse", "HEAD");
|
||||
await rm(path);
|
||||
await writeFile(path, "target\n");
|
||||
const compare = {
|
||||
cwd,
|
||||
base: { kind: "rev" as const, rev: "refs/heads/compare" },
|
||||
head: { kind: "worktree" as const },
|
||||
};
|
||||
const subscription = await streams.subscribe(compare, async (frame) => {
|
||||
frames.push(frame);
|
||||
});
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
frames.length = 0;
|
||||
git("update-ref", "refs/heads/compare", moved);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
const bodies = frames.flatMap((frame) =>
|
||||
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
|
||||
);
|
||||
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
const { listComparison } = await import("./git.js");
|
||||
const fresh = await listComparison(compare);
|
||||
try {
|
||||
const record = await fresh.read(fresh.files[0]);
|
||||
if (record.kind !== "ready") throw new Error("Expected readable type change");
|
||||
expect(bodies).toContainEqual(record.body);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"tracked=%s timestamp-preserving replacements refresh cached bodies",
|
||||
async (tracked) => {
|
||||
|
||||
@@ -163,14 +163,8 @@ export default function contribute(server) {
|
||||
server.addHook("agent.turn.before", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
|
||||
});
|
||||
server.addHook("agent.turn.after", async (event, context) => {
|
||||
const page = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item.type);
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
|
||||
server.addHook("agent.turn.after", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
|
||||
});
|
||||
server.addHook("agent.permission.before", async (event, context) => {
|
||||
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
|
||||
@@ -226,7 +220,6 @@ export default function contribute(server) {
|
||||
{
|
||||
hook: "agent.turn.after",
|
||||
event: { outcome: { kind: "completed" }, timeline: [] },
|
||||
turn: ["user_message", "tool_call"],
|
||||
},
|
||||
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
|
||||
]);
|
||||
|
||||
@@ -157,7 +157,8 @@ test.skipIf(process.platform !== "linux")(
|
||||
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
|
||||
);
|
||||
await waitForTurns(client, agent.id, 2);
|
||||
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
|
||||
const retryTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
|
||||
proof.followup = "RETRY_OK";
|
||||
|
||||
await client.sendMessage(
|
||||
@@ -206,7 +207,8 @@ test.skipIf(process.platform !== "linux")(
|
||||
const envPermission = await waitForPermission(client, agent.id, envCommand);
|
||||
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
|
||||
await waitForTurns(client, agent.id, 5);
|
||||
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
|
||||
const envTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
|
||||
proof.environmentOutput = "created|opened";
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
@@ -330,16 +332,6 @@ test.skipIf(process.platform !== "linux")(
|
||||
600_000,
|
||||
);
|
||||
|
||||
// Turn hooks carry no history; read the reply the way plugins do.
|
||||
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
|
||||
const page = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
});
|
||||
return assistantText(page.entries.map((entry) => entry.item));
|
||||
}
|
||||
|
||||
function assistantText(timeline: readonly AgentTimelineItem[]): string {
|
||||
let text = "";
|
||||
for (const item of timeline) {
|
||||
|
||||
@@ -195,18 +195,21 @@ export function publishAgentStream(
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "completed" },
|
||||
});
|
||||
} else if (event.type === "turn_failed") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
|
||||
});
|
||||
} else if (event.type === "turn_canceled") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "canceled", reason: event.reason },
|
||||
});
|
||||
} else if (event.type === "permission_requested") {
|
||||
|
||||
@@ -564,7 +564,7 @@ export class PluginRuntime {
|
||||
type: "hook",
|
||||
requestId: randomUUID(),
|
||||
name,
|
||||
input: wireEvent(name, event),
|
||||
input: event,
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
@@ -1482,11 +1482,3 @@ function readConnectionId(value: unknown): string | null {
|
||||
const connectionId = Reflect.get(value, "connectionId");
|
||||
return typeof connectionId === "string" ? connectionId : null;
|
||||
}
|
||||
|
||||
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
|
||||
function wireEvent<Name extends keyof PluginHookEvents>(
|
||||
name: Name,
|
||||
event: PluginHookEvents[Name],
|
||||
): PluginHookEvents[Name] {
|
||||
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export function thanksPageHref(fileUrl: string, locale: Locale): string {
|
||||
}
|
||||
|
||||
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
|
||||
export const testFlightBetaUrl = "https://testflight.apple.com/join/Y5HpSQjJ";
|
||||
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
|
||||
export const webAppUrl = "https://app.paseo.sh";
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
downloadUrls,
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
testFlightBetaUrl,
|
||||
thanksPageHref,
|
||||
webAppUrl,
|
||||
AppleIcon,
|
||||
@@ -160,15 +159,13 @@ function Download() {
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
|
||||
<PlatformRow icon={AppleIcon} label="iOS">
|
||||
<PillGroup>
|
||||
{onBeta ? (
|
||||
<DownloadPill href={testFlightBetaUrl} label="TestFlight" external />
|
||||
) : (
|
||||
{!onBeta && (
|
||||
<PlatformRow icon={AppleIcon} label="iOS">
|
||||
<PillGroup>
|
||||
<DownloadPill href={appStoreUrl} label="App Store" external />
|
||||
)}
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@ export default function contribute(server: PluginServerContext) {
|
||||
return;
|
||||
}
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
console.log("Sending a follow-up after out of credits", event.agent.id);
|
||||
await agent.send("Try again.");
|
||||
|
||||
@@ -171,90 +171,3 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st
|
||||
else process.env["COPILOT_TOKEN"] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ remaining: 1500, percentRemaining: 100, usedPct: 0 },
|
||||
{ remaining: 1125, percentRemaining: 75, usedPct: 25 },
|
||||
{ remaining: 0, percentRemaining: 0, usedPct: 100 },
|
||||
])(
|
||||
"reports premium quota with $remaining requests remaining",
|
||||
async ({ remaining, percentRemaining, usedPct }) => {
|
||||
const report = await fetchPremiumQuota({
|
||||
entitlement: 1500,
|
||||
remaining,
|
||||
percent_remaining: percentRemaining,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(report).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "individual",
|
||||
windows: [
|
||||
{
|
||||
id: "premium_interactions",
|
||||
label: "Premium requests",
|
||||
usedPct,
|
||||
remainingPct: percentRemaining,
|
||||
resetsAt: "2026-12-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
details: expect.arrayContaining([
|
||||
{
|
||||
id: "premium_remaining",
|
||||
label: "Premium requests remaining",
|
||||
value: `${remaining} / 1500`,
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{ unlimited: true, percent_remaining: 100, remaining: 1500, entitlement: 1500 },
|
||||
])(
|
||||
"does not invent a finite quota for absent or unlimited premium snapshots (%j)",
|
||||
async (premium) => {
|
||||
const report = await fetchPremiumQuota(premium);
|
||||
expect(report).toMatchObject({
|
||||
status: "available",
|
||||
windows: [],
|
||||
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a percentage without inventing missing counts and falls back to the legacy reset", async () => {
|
||||
const report = await fetchPremiumQuota({ percent_remaining: 60, unlimited: false }, null);
|
||||
expect(report).toMatchObject({
|
||||
status: "available",
|
||||
windows: [{ usedPct: 40, remainingPct: 60, resetsAt: "2026-12-01" }],
|
||||
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchPremiumQuota(
|
||||
premium: unknown,
|
||||
utcReset: string | null = "2026-12-01T00:00:00.000Z",
|
||||
) {
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
const directory = mkdtempSync(join(tmpdir(), "copilot-premium-"));
|
||||
const locator = join(directory, "hosts.yml");
|
||||
try {
|
||||
await writeFile(locator, "github.com:\n oauth_token: fixture-copilot\n");
|
||||
return await fetchUsage({ store: "file", locator }, async () =>
|
||||
jsonResponse({
|
||||
copilot_plan: "individual",
|
||||
quota_reset_date: "2026-12-01",
|
||||
quota_reset_date_utc: utcReset,
|
||||
quota_snapshots: {
|
||||
premium_interactions: premium,
|
||||
chat: { unlimited: true, percent_remaining: 100 },
|
||||
completions: { unlimited: true, percent_remaining: 100 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
unavailable,
|
||||
windowFromUsedPct,
|
||||
type UsageWindow,
|
||||
type UsageAccount,
|
||||
type UsageReport,
|
||||
type UsageDetail,
|
||||
@@ -20,19 +18,6 @@ const ApiOptionalStringSchema = z.preprocess(
|
||||
const CopilotUsageResponseSchema = z.object({
|
||||
copilot_plan: ApiOptionalStringSchema,
|
||||
quota_reset_date: ApiOptionalStringSchema,
|
||||
quota_reset_date_utc: ApiOptionalStringSchema,
|
||||
quota_snapshots: z
|
||||
.object({
|
||||
premium_interactions: z
|
||||
.object({
|
||||
entitlement: z.number().nonnegative().nullish(),
|
||||
remaining: z.number().nonnegative().nullish(),
|
||||
percent_remaining: z.number().min(0).max(100).nullish(),
|
||||
unlimited: z.boolean().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
@@ -87,32 +72,10 @@ export async function fetchUsage(
|
||||
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
|
||||
: [];
|
||||
|
||||
const windows: UsageWindow[] = [];
|
||||
const premium = resp.quota_snapshots?.premium_interactions;
|
||||
if (premium && !premium.unlimited) {
|
||||
if (premium.percent_remaining != null) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "premium_interactions",
|
||||
label: "Premium requests",
|
||||
utilizationPct: 100 - premium.percent_remaining,
|
||||
resetsAt: resp.quota_reset_date_utc || resp.quota_reset_date,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (premium.remaining != null && premium.entitlement != null) {
|
||||
details.push({
|
||||
id: "premium_remaining",
|
||||
label: "Premium requests remaining",
|
||||
value: `${premium.remaining} / ${premium.entitlement}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "available",
|
||||
planLabel: resp.copilot_plan || undefined,
|
||||
windows,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
|
||||
@@ -525,7 +525,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
|
||||
|
||||
## agent.turn.after
|
||||
|
||||
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome }`. `outcome.kind` is `completed`, `failed`, or `canceled`. The payload carries no history. Read the turn from the agent's timeline: `refetch({ direction: "tail", limit: 1, wholeTurns: true })` returns the latest turn whole, as below. Keep the entries whose `turnId` equals `event.turnId`, because a turn that starts before your read, such as a follow-up another plugin sent, replaces this one as the latest. The filtered list is empty in that case, and when `event.turnId` is `null`. Plugins built against an older SDK that read `event.timeline` get an empty array.
|
||||
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below.
|
||||
|
||||
### Send a follow-up when a turn ends
|
||||
|
||||
@@ -545,9 +545,8 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
@@ -563,6 +562,9 @@ Turn ends: "out of credits"
|
||||
→ a new turn starts
|
||||
```
|
||||
|
||||
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
|
||||
Use `cursor` and `direction` to page further when your plugin needs more history.
|
||||
|
||||
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
|
||||
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
|
||||
@@ -514,7 +514,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
|
||||
|
||||
## agent.turn.after {#agentturnafter}
|
||||
|
||||
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。载荷不含历史。请从 Agent 的时间线读取本轮:`refetch({ direction: "tail", limit: 1, wholeTurns: true })` 会完整返回最近一轮,见下面的示例。只保留 `turnId` 等于 `event.turnId` 的条目:如果在读取前已有新一轮开始(例如另一个插件发送的后续消息),最近一轮就不再是本轮。此时过滤后的列表为空;`event.turnId` 为 `null` 时也为空。基于旧版 SDK 构建、仍读取 `event.timeline` 的插件只会拿到空数组。
|
||||
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。
|
||||
|
||||
### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends}
|
||||
|
||||
@@ -531,9 +531,8 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
@@ -549,6 +548,8 @@ Turn ends: "out of credits"
|
||||
→ a new turn starts
|
||||
```
|
||||
|
||||
该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。
|
||||
|
||||
这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。
|
||||
|
||||
## agent.permission.before {#agentpermissionbefore}
|
||||
|
||||
@@ -207,22 +207,6 @@ setting `id` as an option key and each `value` as that option, and sends changes
|
||||
| `ProviderSetting` type | `ProviderOptionControl` |
|
||||
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
|
||||
|
||||
### Turn history in v0.16
|
||||
|
||||
`agent.turn.after` has sent an empty `timeline` since v0.13, and the SDK type no longer declares it.
|
||||
A plugin that reads `event.timeline` now fails typecheck. Read the turn from the agent instead:
|
||||
|
||||
```ts
|
||||
const turn = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
|
||||
```
|
||||
|
||||
The filter drops entries from a turn that started after this one ended, such as a follow-up another plugin sent. The list is empty when such a turn has already started, or when `event.turnId` is `null`.
|
||||
|
||||
Plugins built against an older SDK keep receiving `timeline: []` until removal after 2027-05-31.
|
||||
|
||||
## 5. Recognize half-migration errors
|
||||
|
||||
| Compiler or load error | Meaning and fix |
|
||||
|
||||
@@ -170,21 +170,6 @@ Provider 插件只有一个配置记录 `providerOptions`,并用控件描述
|
||||
| `ProviderSetting` 类型 | `ProviderOptionControl` |
|
||||
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
|
||||
|
||||
### v0.16 中的轮次历史 {#turn-history-in-v016}
|
||||
|
||||
自 v0.13 起,`agent.turn.after` 发送的 `timeline` 始终为空,SDK 类型也不再声明它。读取 `event.timeline` 的插件现在无法通过类型检查。请改为从 Agent 读取本轮:
|
||||
|
||||
```ts
|
||||
const turn = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
|
||||
```
|
||||
|
||||
过滤会去掉本轮结束后才开始的新一轮的条目,例如另一个插件发送的后续消息。如果这样的新一轮已经开始,或 `event.turnId` 为 `null`,列表为空。
|
||||
|
||||
基于旧版 SDK 构建的插件在 2027-05-31 之后移除兼容层之前仍会收到 `timeline: []`。
|
||||
|
||||
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
|
||||
|
||||
| 编译器或加载错误 | 含义与修复方法 |
|
||||
|
||||
@@ -118,7 +118,7 @@ workspace the call creates is background too. With a `parent` and no value, the
|
||||
|
||||
### Timeline handle
|
||||
|
||||
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, `wholeTurns`, and `requestId`. `wholeTurns: true` extends a `tail` or `before` page back to the start of the oldest turn it reaches, so `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn. It requires `features.wholeTurnTimeline`; older hosts ignore it and return a page of `limit` items.
|
||||
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.
|
||||
|
||||
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ nav: API 参考
|
||||
|
||||
### 时间线句柄 {#timeline-handle}
|
||||
|
||||
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection`、`wholeTurns` 和 `requestId`。`wholeTurns: true` 会把 `tail` 或 `before` 页向前扩展到它所触及的最早一轮的开头,因此 `{ direction: "tail", limit: 1, wholeTurns: true }` 返回最近一轮。它需要 `features.wholeTurnTimeline`;旧版主机会忽略它,返回 `limit` 条记录。
|
||||
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。
|
||||
|
||||
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。
|
||||
|
||||
|
||||
@@ -52,20 +52,19 @@ Betas are the best way to get fixes and features early. If you hit a bug, report
|
||||
|
||||
### How to join the beta channel
|
||||
|
||||
| Platform | How to get betas |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| iOS and iPadOS | Join the [Paseo TestFlight beta](https://testflight.apple.com/join/Y5HpSQjJ). |
|
||||
| Android | Download the APK from a prerelease on the [GitHub releases page](https://github.com/getpaseo/paseo/releases). |
|
||||
| Desktop | Open **Settings → About** and select **Beta** under **Release channel**. |
|
||||
| CLI | Run `npm install -g @getpaseo/cli@beta`. Plain `npm install -g @getpaseo/cli` stays on stable. |
|
||||
In the desktop app:
|
||||
|
||||
The desktop app checks for beta updates from the moment you select **Beta**.
|
||||
1. Open **Settings**
|
||||
2. Go to **About**
|
||||
3. Under **Release channel**, select **Beta**
|
||||
|
||||
The app will check for beta updates from then on.
|
||||
|
||||
## App stores
|
||||
|
||||
Desktop releases and the CLI are usually available first. App Store and Play Store releases can lag behind because they go through review.
|
||||
|
||||
The App Store and Play Store listings carry stable builds only. On iOS, the [TestFlight beta](https://testflight.apple.com/join/Y5HpSQjJ) is the beta channel. Every beta is uploaded to it after Apple's beta review, and stable releases are uploaded to it as well, so TestFlight testers stay on the latest build. On Android there is no Play Store beta, so install beta APKs from GitHub.
|
||||
There is no beta channel in the app stores right now — only stable builds are submitted. If you want early Android builds, download the APK from the [GitHub releases page](https://github.com/getpaseo/paseo/releases).
|
||||
|
||||
## What to do if something breaks
|
||||
|
||||
|
||||
@@ -50,20 +50,19 @@ v0.1.92-beta.3
|
||||
|
||||
### 如何加入测试版渠道 {#how-to-join-the-beta-channel}
|
||||
|
||||
| 平台 | 获取测试版的方式 |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| iOS 和 iPadOS | 加入 [Paseo TestFlight 测试版](https://testflight.apple.com/join/Y5HpSQjJ)。 |
|
||||
| Android | 从 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)的预发布版本下载 APK。 |
|
||||
| 桌面端 | 打开 **设置 → 关于**,在 **发布通道** 下选择 **Beta**。 |
|
||||
| CLI | 运行 `npm install -g @getpaseo/cli@beta`。直接运行 `npm install -g @getpaseo/cli` 仍使用稳定版。 |
|
||||
在桌面应用中:
|
||||
|
||||
在桌面应用中选择 **Beta** 后,应用就会检查测试版更新。
|
||||
1. 打开 **设置**
|
||||
2. 进入 **关于**
|
||||
3. 在 **发布通道** 下选择 **Beta**
|
||||
|
||||
之后应用就会检查测试版更新。
|
||||
|
||||
## 应用商店 {#app-stores}
|
||||
|
||||
桌面端版本和 CLI 通常最先提供。App Store 和 Play Store 的版本需要经过审核,可能会滞后。
|
||||
|
||||
App Store 和 Play Store 的商店页面只提供稳定版构建。在 iOS 上,[TestFlight 测试版](https://testflight.apple.com/join/Y5HpSQjJ)就是测试版渠道。每个测试版都会在通过 Apple 的测试版审核后上传到 TestFlight,稳定版也会上传到那里,所以 TestFlight 测试者始终使用最新构建。Android 没有 Play Store 测试版,请从 GitHub 安装测试版 APK。
|
||||
应用商店目前没有测试版渠道,只提交稳定版构建。如果你想提前使用 Android 构建,请从 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载 APK。
|
||||
|
||||
## 出现问题怎么办 {#what-to-do-if-something-breaks}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user