Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 7c3c9109a0 fix(app): keep the context meter's details open after a tap
Ignoring the focus that follows a touch press applied to every hover card,
so on a wide touch screen the context window meter's details opened on the
tap and closed again after the close grace. Whether a card ignores touch is
now one decision on HoverCard, used for both the touch's pointerenter and the
focus after it. The workspace row opts in; the meter keeps opening on a tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:11:05 +00:00
4cc3b69cbe test(app): fail when a tap opens the workspace hover card at all
The touch-tap test only checked that no card was left open after the tap,
which still passes when the touch's pointerenter opens the card during the
tap. That card is what makes iPad Safari drop the tap, so the test now records
whether the card mounted at any point. The trigger also reads the pointer
type from React Native's typed pointer event.

Co-authored-by: Zen <zoho.cn@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
ZenandClaude Opus 5.5 543dc27244 fix(app): a tap on a touch screen opens a workspace row instead of its hover card
On a wide touch screen the workspace hover card opened on the tap's
pointerenter and on the focus the tap left behind. On iPad Safari that
changed the page under the finger, so the tap showed the card and never
opened the workspace. The workspace row's trigger now ignores touch, and
focus that follows a touch press no longer opens a hover card. Mouse hover
and keyboard focus open cards as before. Other triggers, such as the
context meter, which has no press of its own on wide screens, keep opening
on a touch's pointerenter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
35 changed files with 350 additions and 732 deletions
-9
View File
@@ -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.
@@ -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 } });
@@ -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();
+1 -6
View File
@@ -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();
@@ -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 &&
+25 -64
View File
@@ -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],
+55 -8
View File
@@ -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,
@@ -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;
@@ -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-"));
@@ -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;
}
}
@@ -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,