Compare commits

..
Author SHA1 Message Date
Mohamed Boudra f00a940e74 fix(app): preserve scroll ownership through momentum 2026-07-23 01:04:00 +02:00
Mohamed Boudra c81c7bb7c0 fix(app): keep chat scrollable during streaming
Continuous Android stream updates kept bottom-anchor verification pending, so genuine finger drags were mistaken for programmatic corrections. Preserve explicit drag ownership so user scrolling can detach immediately.
2026-07-23 00:35:08 +02:00
126 changed files with 2487 additions and 6600 deletions
-18
View File
@@ -1,23 +1,5 @@
# Changelog
## 0.2.0-beta.4 - 2026-07-23
### Improved
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
### Fixed
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- File edits preserve CRLF line endings and UTF-8 BOMs ([#2277](https://github.com/getpaseo/paseo/pull/2277) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi custom messages marked hidden stay hidden in live and restored chats ([#2280](https://github.com/getpaseo/paseo/pull/2280) by [@isac322](https://github.com/isac322))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
## 0.2.0-beta.3 - 2026-07-22
### Added
+3 -10
View File
@@ -83,16 +83,9 @@ only the route's explicit Unarchive or Restore action changes the archived works
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
## Tabs vs archive
-10
View File
@@ -120,16 +120,6 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
```bash
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
-5
View File
@@ -32,11 +32,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/server": "0.2.0-beta.4",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/server": "0.2.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36487,10 +36487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36501,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36744,7 +36744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,7 +37640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37872,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/highlight": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/highlight": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38648,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -123,16 +123,4 @@ test.describe("mobile bottom sheet reopen", () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await sheet.getByText("Ten second stream", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});
@@ -5,25 +5,6 @@ import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history explains when the workspace has no commits ahead of its base", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
"No commits ahead of main yet",
{ timeout: 30_000 },
);
});
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
@@ -42,7 +23,6 @@ test("commit history shows dates and shares diff layout preferences", async ({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
-23
View File
@@ -239,29 +239,6 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(editor(page)).toContainText("const afterReconnect = 9;");
});
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
const sourcePath = path.join(workspace.repoPath, "windows.ts");
await writeFile(
sourcePath,
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "windows.ts");
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
await editor(page).press("Control+s");
const expected = Buffer.from(
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
"utf8",
).toString("hex");
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
});
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
const sourcePath = path.join(workspace.repoPath, "draft.ts");
-238
View File
@@ -1,238 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
label: string;
renderedWidth: number;
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let releaseSecondRequested = false;
let pauseServerMessages = false;
let secondRunningForwarded = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (!secondRunningMessage || secondRunningForwarded) {
return;
}
ws.send(secondRunningMessage);
secondRunningForwarded = true;
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseServerMessages) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
if (toolCall.status === "running" || toolCall.status === "executing") {
return;
}
if (toolCall.status === "completed") {
// The mock tool completes inside the daemon's coalescing window, so the
// browser naturally receives its authoritative completed state first.
ws.send(message);
resolveFirstCompleted();
return;
}
}
secondCallId ??= toolCall.callId;
if (
toolCall.callId === secondCallId &&
(toolCall.status === "running" || toolCall.status === "executing")
) {
secondRunningMessage = message;
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
// Keep the second real tool call inspectably active. Production providers
// send this same status shape while their work remains in flight.
secondRunningMessage = replaceToolCallStatus(message, "running");
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
if (secondRunningMessage) {
forwardSecondRunning?.();
}
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
);
if (!shimmer) {
throw new Error("Expected a running shimmer inside the badge");
}
const style = getComputedStyle(shimmer);
return {
animationDuration: style.animationDuration,
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
label: shimmer.textContent ?? "",
renderedWidth: shimmer.getBoundingClientRect().width,
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
};
});
}
test("measures an overview heading that becomes loading after its idle mount", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "tool-call-shimmer-",
title: "Tool-call shimmer",
model: "ten-second-stream",
});
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group");
await expect(group).toBeVisible();
const idleGroupHandle = await group.elementHandle();
if (!idleGroupHandle) {
throw new Error("Expected the idle tool-call group to be mounted");
}
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
await gate.waitForSecondRunning();
gate.releaseSecondRunning();
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
const sameGroupNode = await group.evaluate(
(node, previous) => node === previous,
idleGroupHandle,
);
await group.click();
const runningChild = page.getByTestId("tool-call-badge").last();
await expect(runningChild).toBeVisible();
const header = await readShimmerEvidence(group);
const child = await readShimmerEvidence(runningChild);
const evidence = { sameGroupNode, header, child };
await testInfo.attach("tool-call-shimmer-evidence", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(sameGroupNode).toBe(true);
expect(child.endPx).toBeGreaterThan(0);
expect(
header.endPx,
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
).toBeGreaterThan(0);
} finally {
await agent.cleanup();
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
+13 -46
View File
@@ -11,51 +11,6 @@ const EXCLUDED_ANDROID_MODULES = [
"expo-dev-menu-interface",
];
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
def paseoAbiVersionCodes = [
"armeabi-v7a": 1,
"arm64-v8a": 2,
"x86": 3,
"x86_64": 4,
]
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
.toString()
.split(",")
.collect { it.trim() }
.findAll { !it.isEmpty() }
if (paseoArchitectures.size() == 1) {
def paseoAbi = paseoArchitectures[0]
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
if (paseoAbiVersionCode == null) {
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
}
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
}
`;
function configureFdroidAppBuildGradle(contents) {
let configuredContents = contents;
if (!configuredContents.includes("dependenciesInfo {")) {
const androidBlock = "android {";
if (!configuredContents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
configuredContents = configuredContents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
}
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
}
return configuredContents;
}
function withFdroidAutolinking(config) {
config = withDangerousMod(config, [
"android",
@@ -110,7 +65,19 @@ function withFdroidAutolinking(config) {
});
return withAppBuildGradle(config, (modConfig) => {
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
return modConfig;
}
const androidBlock = "android {";
if (!modConfig.modResults.contents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
return modConfig;
});
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
@@ -126,15 +126,10 @@ function createDriverHarness(input?: {
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollAttempts: boolean[] = [];
let scrollToBottomBehavior = () => {
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
};
const scrollToBottom = (animated: boolean) => {
scrollAttempts.push(animated);
scrollToBottomBehavior();
};
});
const modeChanges: BottomAnchorMode[] = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
@@ -155,10 +150,7 @@ function createDriverHarness(input?: {
context,
driver,
scheduler,
scrollAttempts,
setScrollToBottomBehavior(next: () => void) {
scrollToBottomBehavior = next;
},
scrollToBottom,
modeChanges,
};
}
@@ -218,7 +210,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
@@ -237,7 +229,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
@@ -245,97 +237,6 @@ describe("bottom anchor controller driver", () => {
});
});
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("preserves a blocked route anchor when layout moves after drag release", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("lets a user scroll away supersede a blocked route anchor", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isNearBottom: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
@@ -353,7 +254,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
@@ -377,50 +278,12 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
mode: "detached",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.endUserScroll({ isNearBottom: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(1);
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -437,7 +300,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
@@ -458,7 +321,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -479,7 +342,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
@@ -492,7 +355,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -504,7 +367,7 @@ describe("bottom anchor controller driver", () => {
},
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -514,16 +377,16 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -541,7 +404,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
@@ -552,7 +415,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
@@ -575,7 +438,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
@@ -601,7 +464,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
@@ -637,7 +500,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
@@ -646,7 +509,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
@@ -658,7 +521,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -689,7 +552,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
@@ -699,7 +562,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -743,7 +606,7 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -754,7 +617,7 @@ describe("bottom anchor controller driver", () => {
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -791,14 +654,14 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -69,7 +69,7 @@ interface BottomAnchorControllerDriver {
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
endUserScroll: () => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -414,14 +414,10 @@ function createBottomAnchorControllerDriver(
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
) => {
if (isUserScrollActive) {
return;
}
if (attemptHandle) {
return;
}
@@ -507,35 +503,18 @@ function createBottomAnchorControllerDriver(
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
cancelPendingRequest("user_scroll_started");
},
endUserScroll(params) {
endUserScroll() {
isUserScrollActive = false;
if (params.isNearBottom) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
if (pendingRequest) {
evaluate(false, "user_scroll_end");
return;
}
if (
!input.isNearBottom() ||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
if (mode !== "sticky-bottom") {
return;
}
if (input.isNearBottom()) {
markStickyMeasurementVerified();
return;
}
if (mode === "sticky-bottom") {
this.detachByUser();
}
this.detachByUser();
},
detachByUser() {
if (mode === "detached") {
@@ -621,6 +600,9 @@ function createBottomAnchorControllerDriver(
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
if (mode === "sticky-bottom" && !nextIsNearBottom) {
this.detachByUser();
}
return;
}
if (
@@ -792,8 +774,8 @@ export function useBottomAnchorController(input: {
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isNearBottom: boolean }) {
driverRef.current?.endUserScroll(params);
endUserScroll() {
driverRef.current?.endUserScroll();
},
detachByUser() {
driverRef.current?.detachByUser();
@@ -278,19 +278,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
});
},
);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
@@ -305,7 +292,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isScrollEventNearBottom(event);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
@@ -341,15 +334,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.beginUserScroll();
});
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
const handleScrollEndDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
bottomAnchorController.endUserScroll();
});
});
@@ -357,14 +347,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
},
);
const handleMomentumScrollEnd = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
@@ -199,89 +199,6 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("keeps bottom anchoring through subpixel browser rounding", () => {
const scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4959.1708984375,
});
scrollTo.mockClear();
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4960.5,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4967,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -22,7 +22,6 @@ type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
@@ -89,8 +88,7 @@ function getScrollContainerDistanceFromBottom(
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -11,10 +11,9 @@ import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -204,14 +203,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4],
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
@@ -257,39 +248,6 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
return {
height: Math.max(
0,
containerHeight -
contentPosition -
handleHeight -
animatedKeyboardState.get().heightWithinContainer,
),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
@@ -494,16 +452,12 @@ export interface AdaptiveModalSheetProps {
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
footerContainerStyle?: StyleProp<ViewStyle>;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
contentContainerStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
}
export function AdaptiveModalSheet({
@@ -513,14 +467,11 @@ export function AdaptiveModalSheet({
onDismiss,
children,
footer,
footerContainerStyle,
snapPoints,
testID,
desktopMaxWidth,
scrollable = true,
presentation,
contentContainerStyle,
sizeContentToCurrentSnapPoint = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -539,37 +490,31 @@ export function AdaptiveModalSheet({
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
// Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web.
() => [
styles.bottomSheetContent,
[
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
[compactSafeAreaPadding.contentPaddingBottom],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const footerStyle = useMemo(
() => [
styles.footer,
footerContainerStyle,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
@@ -654,25 +599,6 @@ export function AdaptiveModalSheet({
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
const sheetContent = (
<>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
@@ -690,11 +616,19 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
sheetContent
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -706,7 +640,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
@@ -714,7 +648,7 @@ export function AdaptiveModalSheet({
</ScrollView>
</View>
) : (
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
File diff suppressed because it is too large Load Diff
@@ -18,16 +18,17 @@ interface ContextWindowMeterProps {
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
@@ -73,7 +74,7 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -84,14 +85,12 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
@@ -104,7 +103,6 @@ export function ContextWindowMeter({
serverId,
provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -125,7 +123,7 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
@@ -7,7 +7,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="4 4 56 56" fill={color}>
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);
@@ -7,7 +7,7 @@ interface PiIconProps {
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="100 100 600 600" fill={color}>
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
+1 -3
View File
@@ -2657,9 +2657,7 @@ function computeShimmerMetrics(input: {
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
);
const isWebShimmer = input.isLoading && isWeb;
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
};
}
describe("model browser initial view", () => {
const codex = provider("codex", "Codex");
const pi = provider("pi", "Pi");
it("opens a sole provider directly", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
selectedProvider: "",
selectedModel: "",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the selected provider when its model is not a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the provider overview when the selected model is a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(["pi:pi-pro"]),
}),
).toEqual({ kind: "all" });
});
});
@@ -1,40 +0,0 @@
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
export type ModelBrowserView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
export function resolveInitialModelBrowserView({
providers,
selectedProvider,
selectedModel,
favoriteKeys,
}: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
}): ModelBrowserView {
const singleProvider = providers.length === 1 ? providers[0] : undefined;
if (singleProvider) {
return {
kind: "provider",
providerId: singleProvider.id,
providerLabel: singleProvider.label,
};
}
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
const shouldOpenSelectedProvider =
selectedProvider.length > 0 &&
selectedModel.length > 0 &&
!favoriteKeys.has(selectedFavoriteKey);
if (shouldOpenSelectedProvider) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider) {
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
}
return { kind: "all" };
}
File diff suppressed because it is too large Load Diff
+34 -61
View File
@@ -24,7 +24,6 @@ import {
type ViewStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -101,8 +100,6 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
/** Overrides the mobile scroll container spacing for custom child content. */
mobileChildrenContentContainerStyle?: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -937,7 +934,6 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
searchResetKey: number;
searchPlaceholder: string;
@@ -951,7 +947,6 @@ interface MobileBodyProps {
handleSelect: (id: string) => void;
renderOption: RenderOptionFn | undefined;
children: ReactNode;
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
@@ -971,10 +966,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
props.children
@@ -1005,46 +996,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -1237,7 +1222,6 @@ export function Combobox({
title,
header,
mobileChildrenScrollEnabled = true,
mobileChildrenContentContainerStyle,
presentation,
open,
onOpenChange,
@@ -1257,7 +1241,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
@@ -1532,7 +1515,6 @@ export function Combobox({
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle}
presentation={presentation}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
@@ -1545,7 +1527,6 @@ export function Combobox({
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
safeAreaBottom={safeAreaInsets.bottom}
>
{children}
</MobileComboboxBody>
@@ -1588,14 +1569,6 @@ export function Combobox({
}
const styles = StyleSheet.create((theme) => ({
mobileSheetFrame: {
flex: 1,
minHeight: 0,
},
mobileSheetBody: {
flex: 1,
minHeight: 0,
},
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
@@ -6,9 +6,11 @@ import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -380,6 +382,7 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
() =>
@@ -392,6 +395,14 @@ export function WorkspaceSetupDialog() {
[composerState, pendingAction],
);
const composerFooter = useMemo(
() =>
isCompact && agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -446,6 +457,7 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</FileDropZone>
@@ -1,173 +0,0 @@
import { forwardRef, useCallback, type ComponentType } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
export interface AgentControlIconProps {
size?: number;
color?: string;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
interface AgentControlTriggerProps {
icon: AgentControlIcon;
iconColor?: string;
surface: "toolbar" | "sheet";
label: string;
value?: string;
showToolbarLabel?: boolean;
showCaret?: boolean;
open?: boolean;
disabled?: boolean;
onPress: () => void;
accessibilityLabel: string;
testID?: string;
}
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
function AgentControlTrigger(
{
icon: Icon,
iconColor,
surface,
label,
value,
showToolbarLabel = true,
showCaret = false,
open = false,
disabled = false,
onPress,
accessibilityLabel,
testID,
},
ref,
) {
const { glyphSize } = useComposerControlLayout();
const isSheet = surface === "sheet";
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
const resolvedIconColor = iconColor ?? styles.iconColor.color;
const showValue = isSheet || showToolbarLabel;
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
isSheet ? styles.sheetRow : styles.toolbarControl,
!isSheet && !showToolbarLabel && styles.toolbarIconOnly,
hovered && (isSheet ? styles.sheetRowInteractive : styles.hovered),
(pressed || open) && (isSheet ? styles.sheetRowInteractive : styles.pressed),
disabled && styles.disabled,
],
[disabled, isSheet, open, showToolbarLabel],
);
return (
<ComboboxTrigger
ref={ref}
collapsable={false}
disabled={disabled}
onPress={onPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
chevron={showCaret ? undefined : null}
>
{isSheet ? (
<View style={styles.sheetGlyph}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</View>
) : (
<ComposerToolbarGlyph size={resolvedGlyphSize}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</ComposerToolbarGlyph>
)}
{isSheet ? (
<Text style={styles.sheetLabel} numberOfLines={1}>
{label}
</Text>
) : null}
{showValue ? (
<Text style={isSheet ? styles.sheetValue : styles.toolbarValue} numberOfLines={1}>
{value ?? label}
</Text>
) : null}
</ComboboxTrigger>
);
},
);
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
toolbarIconOnly: {
width: 28,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
},
toolbarValue: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
sheetRow: {
minHeight: 44,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginHorizontal: -theme.spacing[1],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: theme.colors.surface1,
},
sheetRowInteractive: {
backgroundColor: theme.colors.surface2,
},
sheetGlyph: {
width: 20,
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
sheetLabel: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
sheetValue: {
maxWidth: "45%",
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
hovered: {
backgroundColor: theme.colors.surface2,
},
pressed: {
backgroundColor: theme.colors.surface0,
},
disabled: {
opacity: 0.5,
},
iconColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
return (
<View
style={size >= 20 ? styles.native : styles.web}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
>
{children}
</View>
);
}
const styles = StyleSheet.create({
web: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
native: {
width: 20,
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
});
File diff suppressed because it is too large Load Diff
@@ -1,37 +0,0 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphSize: number;
presentation: ComposerControlPresentation;
}
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphSize: 16,
presentation: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
},
};
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
export function ComposerControlLayoutProvider({
value,
children,
}: {
value: ComposerControlLayoutValue;
children: ReactNode;
}) {
return (
<ComposerControlLayoutContext.Provider value={value}>
{children}
</ComposerControlLayoutContext.Provider>
);
}
export function useComposerControlLayout(): ComposerControlLayoutValue {
return useContext(ComposerControlLayoutContext);
}
@@ -1,168 +0,0 @@
import { describe, expect, it } from "vitest";
import {
COMPOSER_TOOLBAR_GEOMETRY,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
} from "./layout";
describe("composer control layout", () => {
it("removes labels in priority order as the toolbar narrows", () => {
expect(resolveComposerControlPresentation("full")).toEqual({
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
});
expect(resolveComposerControlPresentation("condensed")).toEqual({
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
});
expect(resolveComposerControlPresentation("tight")).toEqual({
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
});
});
it("uses local available width and hysteresis to avoid density churn", () => {
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
};
expect(
resolveComposerControlDensity({
availableWidth: 420,
currentDensity: "full",
controls,
}),
).toBe("full");
expect(
resolveComposerControlDensity({
availableWidth: 380,
currentDensity: "full",
controls,
}),
).toBe("condensed");
expect(
resolveComposerControlDensity({
availableWidth: 290,
currentDensity: "condensed",
controls,
}),
).toBe("condensed");
expect(
resolveComposerControlDensity({
availableWidth: 280,
currentDensity: "condensed",
controls,
}),
).toBe("tight");
expect(
resolveComposerControlDensity({
availableWidth: 300,
currentDensity: "tight",
controls,
}),
).toBe("tight");
expect(
resolveComposerControlDensity({
availableWidth: 312,
currentDensity: "tight",
controls,
}),
).toBe("condensed");
});
it("budgets extra features and larger text before restoring full labels", () => {
const base = {
availableWidth: 430,
currentDensity: "condensed" as const,
};
expect(
resolveComposerControlDensity({
...base,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
fontScale: 1,
},
}),
).toBe("full");
expect(
resolveComposerControlDensity({
...base,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
fontScale: 1,
},
}),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
fontScale: 1.25,
},
}),
).toBe("condensed");
});
it("condenses before a labeled feature would overflow", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
fontScale: 1,
},
};
expect(
resolveComposerControlDensity({
...base,
controls: { ...base.controls, features: [{ type: "toggle" }] },
}),
).toBe("full");
expect(
resolveComposerControlDensity({
...base,
controls: {
...base.controls,
features: [{ type: "select", label: "A much longer localized feature label" }],
},
}),
).toBe("condensed");
});
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
controlSize: 28,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
});
});
@@ -1,134 +0,0 @@
export type ComposerControlDensity = "full" | "condensed" | "tight";
export interface ComposerControlPresence {
hasModel: boolean;
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
fontScale: number;
}
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
export interface ComposerControlPresentation {
showCarets: boolean;
showThinkingLabel: boolean;
showModeLabel: boolean;
aggregateFeatures: boolean;
}
export const COMPOSER_TOOLBAR_GEOMETRY = {
controlSize: 28,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
} as const;
const DENSITY_HYSTERESIS = 12;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
}
function sumControlWidths(widths: number[]): number {
if (widths.length === 0) return 0;
return (
widths.reduce((total, width) => total + width, 0) +
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
);
}
function estimateLabelWidth(label: string, fontScale: number): number {
return Array.from(label).length * 7 * fontScale;
}
function resolveFeatureControlWidth(
feature: ComposerFeatureControlPresence,
fontScale: number,
): number {
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
return (
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
estimateLabelWidth(feature.label, fontScale)
);
}
function resolveCondensedFloor(controls: ComposerControlPresence): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
return sumControlWidths(widths);
}
function resolveFullFloor(controls: ComposerControlPresence): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) widths.push(50 + 70 * fontScale);
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
if (controls.hasMode) widths.push(54 + 96 * fontScale);
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale));
}
return sumControlWidths(widths);
}
export function resolveComposerControlDensity(input: {
availableWidth: number;
currentDensity: ComposerControlDensity;
controls: ComposerControlPresence;
}): ComposerControlDensity {
const fullFloor = resolveFullFloor(input.controls);
const condensedFloor = resolveCondensedFloor(input.controls);
if (input.currentDensity === "full") {
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
}
if (input.currentDensity === "condensed") {
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
return "condensed";
}
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
return "tight";
}
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
if (density === "full") {
return {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
};
}
if (density === "condensed") {
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
};
}
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
};
}
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
return platform === "native" ? 20 : 16;
}
@@ -1,4 +1,5 @@
import {
memo,
useCallback,
useMemo,
useRef,
@@ -7,7 +8,7 @@ import {
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -21,6 +22,7 @@ import {
ShieldPlus,
ShieldQuestionMark,
} from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -30,6 +32,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
@@ -38,11 +41,15 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export type AgentModeControlPlacement = "toolbar" | "footer";
function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) {
return placement === "footer" ? isCompact : !isCompact;
}
interface ModeIconProps {
size?: number;
color?: string;
@@ -95,7 +102,7 @@ function ModeComboboxOption({
);
}
export interface AgentModeControlValue {
interface AgentModeControlViewProps {
provider: string;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
@@ -108,24 +115,20 @@ function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
export function AgentModeControl({
function AgentModeControlView({
provider,
providerDefinitions,
modeOptions,
selectedModeId,
onSelectMode,
disabled = false,
surface = "toolbar",
onClose,
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const { presentation } = useComposerControlLayout();
const { t } = useTranslation();
const { isActiveComposer } = useComposerKeyboardScope();
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
const anchorRef = useRef<View>(null);
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
const openRef = useRef(false);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -137,7 +140,7 @@ export function AgentModeControl({
const visuals = selectedMode
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
: undefined;
const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot;
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined;
const iconColor = theme.colors.foregroundMuted;
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
@@ -151,18 +154,10 @@ export function AgentModeControl({
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, searchQuery]);
const handleOpenChange = useCallback(
(next: boolean) => {
const wasOpen = openRef.current;
openRef.current = next;
setOpen(next);
if (!next) {
setSearchQuery("");
if (wasOpen) onClose?.();
}
},
[onClose],
);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (!next) setSearchQuery("");
}, []);
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
const handleSelect = useCallback(
@@ -213,6 +208,18 @@ export function AgentModeControl({
[provider, providerDefinitions, theme.colors.foreground],
);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.chip,
hovered && styles.chipHovered,
(pressed || open) && styles.chipPressed,
disabled && styles.chipDisabled,
],
[open, disabled],
);
const labelStyle = styles.chipLabel;
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: t("agentControls.mode.title"),
@@ -231,23 +238,21 @@ export function AgentModeControl({
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
<ComboboxTrigger
ref={anchorRef}
icon={Icon}
iconColor={iconColor}
surface={surface}
label={t("agentControls.mode.title")}
value={selectedModeLabel}
showToolbarLabel={presentation.showModeLabel}
showCaret={surface === "toolbar" && presentation.showCarets}
open={open}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
/>
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text>
</ComboboxTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -264,7 +269,6 @@ export function AgentModeControl({
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={260}
header={sheetHeader}
renderOption={renderOption}
/>
@@ -278,10 +282,21 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
export function useLiveAgentModeControl(
serverId: string,
agentId: string,
): AgentModeControlValue | null {
interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
isCompactLayout,
}: AgentModeControlProps) {
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
@@ -334,20 +349,82 @@ export function useLiveAgentModeControl(
[agentId, client, slice?.provider, toast, updatePreferences],
);
return useMemo(() => {
if (!slice || availableModes.length === 0) return null;
return {
provider: slice.provider,
providerDefinitions,
modeOptions: availableModes,
selectedModeId: slice.currentModeId,
onSelectMode: handleSelectMode,
disabled: !client,
};
}, [availableModes, client, handleSelectMode, providerDefinitions, slice]);
if (!slice || availableModes.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={slice.provider}
providerDefinitions={providerDefinitions}
modeOptions={availableModes}
selectedModeId={slice.currentModeId}
onSelectMode={handleSelectMode}
disabled={!client}
/>
);
});
export interface DraftAgentModeControlProps {
selectedProvider: AgentProvider | null;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export function DraftAgentModeControl({
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
placement,
isCompactLayout,
}: DraftAgentModeControlProps) {
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={modeOptions}
selectedModeId={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
/>
);
}
const styles = StyleSheet.create((theme) => ({
chip: {
height: 28,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
chipHovered: {
backgroundColor: theme.colors.surface2,
},
chipPressed: {
backgroundColor: theme.colors.surface0,
},
chipDisabled: {
opacity: 0.5,
},
chipLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,275 +0,0 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { getProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
const MODEL_ROW_STRIDE = 44;
const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5;
const FIXED_MODEL_VIEWPORT_HEIGHT =
MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS;
interface CompactModelSheetProps {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
isLoading: boolean;
favoriteKeys: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
onOpen?: () => void;
onClose?: () => void;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
children: ReactNode;
}
function shortModelLabel(label: string): string {
const separatorIndex = label.lastIndexOf("/");
return separatorIndex === -1 ? label : label.slice(separatorIndex + 1);
}
export function CompactModelSheet({
providers,
selectedProvider,
selectedModel,
onSelect,
isLoading,
favoriteKeys,
onToggleFavorite,
onOpen,
onClose,
onRetryProvider,
isRetryingProvider = false,
disabled = false,
serverId = null,
glyphSize,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
const usesBottomSheet = useIsCompactFormFactor();
const [isOpen, setIsOpen] = useState(false);
const browser = useModelBrowser({
providers,
selectedProvider,
selectedModel,
isLoading,
favoriteKeys,
serverId,
});
const { prepareToOpen, reset } = browser;
const ProviderIcon =
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
const compactFooter = useMemo(
() =>
usesBottomSheet ? (
<View style={styles.compactFooter} testID="agent-controls-settings-list">
<View style={styles.modelViewportDivider} />
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View>
</View>
) : undefined,
[children, usesBottomSheet],
);
const open = useCallback(() => {
Keyboard.dismiss();
prepareToOpen();
setIsOpen(true);
onOpen?.();
}, [onOpen, prepareToOpen]);
const close = useCallback(() => {
setIsOpen(false);
reset();
onClose?.();
}, [onClose, reset]);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
close();
},
[close, onSelect],
);
const toggle = useCallback(() => {
if (isOpen) {
close();
return;
}
open();
}, [close, isOpen, open]);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, isOpen],
);
return (
<>
<ComboboxTrigger
collapsable={false}
disabled={disabled}
onPress={toggle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", {
model: browser.selectedModelLabel,
})}
testID="combined-model-selector"
chevron={null}
>
{ProviderIcon ? (
<ComposerToolbarGlyph size={glyphSize}>
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(browser.triggerLabel)}
</Text>
</ComboboxTrigger>
<AdaptiveModalSheet
header={browser.header}
visible={isOpen}
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
footer={compactFooter}
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
contentContainerStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
<View
style={[
styles.modelViewport,
usesBottomSheet ? styles.flexibleModelViewport : styles.fixedModelViewport,
]}
testID="agent-controls-model-viewport"
>
<ModelBrowser
state={browser}
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling="independent"
/>
</View>
{!usesBottomSheet ? (
<>
<View style={styles.modelViewportDivider} />
<ScrollView
style={styles.controlsScroll}
contentContainerStyle={styles.controlsContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID="agent-controls-settings-list"
>
{children}
</ScrollView>
</>
) : null}
</AdaptiveModalSheet>
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
providerIcon: {
color: theme.colors.foregroundMuted,
},
sheetBody: {
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
gap: 0,
},
modelViewport: {
overflow: "hidden",
backgroundColor: theme.colors.surfaceSidebar,
},
flexibleModelViewport: {
flex: 1,
minHeight: 0,
},
fixedModelViewport: {
height: FIXED_MODEL_VIEWPORT_HEIGHT,
minHeight: FIXED_MODEL_VIEWPORT_HEIGHT,
},
modelViewportDivider: {
height: 1,
flexShrink: 0,
backgroundColor: theme.colors.border,
},
controlsScroll: {
flex: 1,
minHeight: 0,
},
compactFooterContainer: {
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: 0,
paddingHorizontal: 0,
paddingTop: 0,
borderTopWidth: 0,
},
compactFooter: {
minWidth: 0,
},
compactControlsContent: {
paddingBottom: 0,
},
controlsContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[3],
gap: theme.spacing[1],
},
}));
@@ -19,7 +19,6 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]);
const labelStyle = useMemo(() => [styles.label, isHovered && styles.labelHovered], [isHovered]);
return (
<View style={styles.row}>
<Pressable
@@ -33,7 +32,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
style={bodyStyle}
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={labelStyle} numberOfLines={1}>
<Text style={styles.label} numberOfLines={1}>
{t("importSession.title")}
</Text>
</Pressable>
@@ -51,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
@@ -60,10 +59,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
label: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
labelHovered: {
color: theme.colors.foreground,
},
}));
@@ -9,6 +9,7 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
@@ -608,6 +609,57 @@ export function WorkspaceDraftAgentTab({
focusInputRef.current = focus;
}, []);
const handleProviderSelectWithFocus = useCallback(
(provider: Parameters<typeof composerState.setProviderFromUser>[0]) => {
composerState.setProviderFromUser(provider);
focusInputRef.current?.();
},
[composerState],
);
const handleModeSelectWithFocus = useCallback(
(modeId: string) => {
composerState.setModeFromUser(modeId);
focusInputRef.current?.();
},
[composerState],
);
const handleModelSelectWithFocus = useCallback(
(modelId: string) => {
composerState.setModelFromUser(modelId);
focusInputRef.current?.();
},
[composerState],
);
const handleProviderAndModelSelectWithFocus = useCallback(
(
provider: Parameters<typeof composerState.setProviderAndModelFromUser>[0],
modelId: string,
) => {
composerState.setProviderAndModelFromUser(provider, modelId);
focusInputRef.current?.();
},
[composerState],
);
const handleThinkingOptionSelectWithFocus = useCallback(
(optionId: string) => {
composerState.setThinkingOptionFromUser(optionId);
focusInputRef.current?.();
},
[composerState],
);
const handleSetFeatureWithFocus = useCallback(
(featureId: string, value: unknown) => {
composerState.agentControls.onSetFeature?.(featureId, value);
focusInputRef.current?.();
},
[composerState],
);
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
@@ -628,11 +680,39 @@ export function WorkspaceDraftAgentTab({
const composerAgentControls = useMemo(
() => ({
...composerState.agentControls,
onSelectProvider: handleProviderSelectWithFocus,
onSelectMode: handleModeSelectWithFocus,
onSelectModel: handleModelSelectWithFocus,
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: handleDropdownCloseFocus,
disabled: isSubmitting,
}),
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
[
composerState.agentControls,
handleProviderSelectWithFocus,
handleModeSelectWithFocus,
handleModelSelectWithFocus,
handleProviderAndModelSelectWithFocus,
handleThinkingOptionSelectWithFocus,
handleSetFeatureWithFocus,
handleDropdownCloseFocus,
isSubmitting,
],
);
const composerFooter = useMemo(
() =>
isCompactComposerLayout ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompactComposerLayout, composerAgentControls],
);
return (
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
@@ -690,6 +770,7 @@ export function WorkspaceDraftAgentTab({
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
+79 -13
View File
@@ -232,7 +232,6 @@ function renderContextWindowMeter(
serverId: string,
provider: string | null,
pending: boolean,
glyphSize: number,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
@@ -247,16 +246,21 @@ function renderContextWindowMeter(
serverId={serverId}
provider={provider}
pending={pending}
glyphSize={glyphSize}
/>
);
}
function resolveContextWindowPlacement(
meter: ReactElement | null,
reserveSlot: boolean,
): ReactNode {
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
if (isMobile) {
return { beforeVoiceContent: null, footerInlineContent: meter };
}
return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerInlineContent: null,
};
}
interface RenderLeftContentArgs {
@@ -298,6 +302,23 @@ interface RenderAttachmentTrayArgs {
};
}
function renderComposerFooter(
footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>
{footer}
{footerInlineContent}
</View>
</View>
</View>
);
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
selectedAttachments,
@@ -840,6 +861,8 @@ interface ComposerProps {
agentControls?: DraftAgentControlsProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** Rendered below the input, inside the keyboard-shifted container. */
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
@@ -1049,6 +1072,7 @@ export function Composer({
onAttentionPromptSend,
agentControls,
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
@@ -1776,7 +1800,6 @@ export function Composer({
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const contextWindowMeter = useMemo(
() =>
@@ -1784,25 +1807,24 @@ export function Composer({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
),
[
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
[contextWindowMeter, hasAgent],
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
);
const hasGithubAttachment = useMemo(
@@ -2133,6 +2155,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
@@ -2168,6 +2191,50 @@ const styles = StyleSheet.create((theme: Theme) => ({
maxWidth: MAX_CONTENT_WIDTH,
gap: theme.spacing[3],
},
footer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
// Negative margin pulls the footer up against the input area's paddingBottom.
// On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more.
marginTop: {
xs: -(theme.spacing[4] - 3),
md: -theme.spacing[3],
},
alignItems: "center",
paddingBottom: {
xs: 0,
md: theme.spacing[2],
},
},
footerContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
// On mobile, the negative margins below cancel each glyph's internal padding
// to reach the composer border; this inset adds a small visual gap from it.
paddingLeft: {
xs: 5,
md: 10,
},
paddingRight: {
xs: 5,
md: 10,
},
},
footerLeft: {
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied.
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
},
messageInputContainer: {
position: "relative",
width: "100%",
@@ -2190,7 +2257,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
contextWindowMeterSlot: {
width: 28,
height: 28,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
@@ -485,6 +485,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
bumpHistorySyncGeneration,
refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId),
}).catch((error) => {
console.error("[SessionProvider] resume revalidation failed", {
serverId,
error: toErrorMessage(error),
});
});
},
[bumpHistorySyncGeneration, serverId],
@@ -32,21 +32,4 @@ describe("session resume revalidation", () => {
expect(revalidated).toBe(false);
expect(calls).toEqual([]);
});
it("defers stale resume revalidation while the host is disconnected", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshDirectories: async () => {
calls.push("directories");
throw new Error("Host server is not connected");
},
});
expect(revalidated).toBe(false);
expect(calls).toEqual(["history:server", "directories"]);
});
});
@@ -10,11 +10,7 @@ export async function revalidateSessionAfterResume(input: {
return false;
}
try {
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
} catch {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
}
+12 -12
View File
@@ -18,10 +18,10 @@ const CATALOG_DATA = [
title: "Agoragentic",
description:
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
version: "1.3.6",
version: "1.3.3",
iconId: "agoragentic-acp",
installLink: "https://agoragentic.com",
command: ["npx", "-y", "agoragentic-mcp@1.3.6", "--acp"],
command: ["npx", "-y", "agoragentic-mcp@1.3.3", "--acp"],
},
{
id: "amp-acp",
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.36",
version: "0.2.35",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
},
{
id: "dirac",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.178.0",
version: "0.177.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.178.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,19 +173,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.9.22",
version: "0.9.21",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.52.0",
version: "0.51.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.51.0", "--acp"],
},
{
id: "glm-acp-agent",
@@ -302,10 +302,10 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.4",
version: "1.1.3",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
},
{
id: "qwen-code",
+1
View File
@@ -4,6 +4,7 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
@@ -1,34 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, it } from "vitest";
import { explorerFileFromReadResult } from "./read-result";
function textRead(bytes: Uint8Array): FileReadResult {
return {
bytes,
mime: "text/plain",
size: bytes.byteLength,
path: "notes.txt",
kind: "text",
modifiedAt: "2026-07-21T00:00:00.000Z",
};
}
describe("explorerFileFromReadResult", () => {
it("records and hides a leading UTF-8 BOM", () => {
const file = explorerFileFromReadResult(
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
);
expect(file).toMatchObject({ content: "hi", hasBom: true });
});
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
const embedded = explorerFileFromReadResult(
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
);
expect(plain.hasBom).toBe(false);
expect(embedded.hasBom).toBe(false);
});
});
@@ -8,13 +8,8 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
modifiedAt: file.modifiedAt,
};
}
function hasUtf8Bom(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
}
+5 -114
View File
@@ -77,17 +77,8 @@ function ready(
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
interface MakeModelInput {
content?: string;
hasBom?: boolean;
}
function makeModel(input: MakeModelInput = {}) {
const file = {
content: input.content ?? "one",
hasBom: input.hasBom ?? false,
version: ready() as Extract<FileVersion, { status: "ready" }>,
};
function makeModel() {
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
const session = new FileSession(file);
const clock = new TestClock();
return { model: new FileEditorModel({ file, session, clock }), session, clock };
@@ -151,55 +142,10 @@ describe("FileEditorModel", () => {
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps CRLF content in file form", async () => {
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
expect(model.getSnapshot()).toMatchObject({
content: "one\r\ntwo\r\n",
lineSeparator: "\r\n",
});
model.edit("one\r\ntwo\r\nthree\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "one\r\ntwo\r\nthree\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
]);
});
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("saved\r\n");
await model.save();
model.edit("saved again\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFsaved\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
{
content: "\uFEFFsaved again\r\n",
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
},
]);
});
test("uses the first line separator when a file mixes styles", () => {
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
@@ -209,26 +155,6 @@ describe("FileEditorModel", () => {
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("adopts the format from a clean remote refresh", async () => {
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
session.file = {
content: "remote\n",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
};
model.receiveFileVersion(session.file.version);
await Promise.resolve();
expect(model.getSnapshot().lineSeparator).toBe("\n");
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
const { model, session } = makeModel();
const reads: Array<(file: FileEditorFile) => void> = [];
@@ -238,9 +164,9 @@ describe("FileEditorModel", () => {
model.receiveFileVersion(firstVersion);
model.receiveFileVersion(latestVersion);
reads[0]?.({ content: "first", hasBom: false, version: firstVersion });
reads[0]?.({ content: "first", version: firstVersion });
await Promise.resolve();
reads[1]?.({ content: "latest", hasBom: false, version: latestVersion });
reads[1]?.({ content: "latest", version: latestVersion });
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
@@ -260,21 +186,6 @@ describe("FileEditorModel", () => {
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
await model.overwrite();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal\r\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
},
]);
});
test("reload discards a conflicted local buffer for the disk contents", async () => {
const { model, session } = makeModel();
model.edit("local");
@@ -282,7 +193,7 @@ describe("FileEditorModel", () => {
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", hasBom: false, version: diskVersion };
session.file = { content: "disk", version: diskVersion };
model.receiveFileVersion(diskVersion);
await model.reload();
@@ -290,26 +201,6 @@ describe("FileEditorModel", () => {
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("adopts the remote format when reloading a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
model.receiveFileVersion(diskVersion);
await model.reload();
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("reports failed saves without losing the local buffer", async () => {
const { model, session } = makeModel();
session.nextWrite = new Error("disk full");
+1 -19
View File
@@ -1,12 +1,10 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
@@ -15,7 +13,6 @@ export interface FileEditorSnapshot {
export interface FileEditorFile {
content: string;
hasBom: boolean;
version: Extract<FileVersion, { status: "ready" }>;
}
@@ -52,7 +49,6 @@ export class FileEditorModel {
private disposed = false;
private observedWhileSaving: FileVersion | null = null;
private persistedContent: string;
private hasBom: boolean;
constructor(input: {
file: FileEditorFile;
@@ -62,11 +58,9 @@ export class FileEditorModel {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.hasBom = input.file.hasBom;
this.snapshot = {
status: "clean",
content: input.file.content,
lineSeparator: detectLineSeparator(input.file.content),
modified: false,
version: input.file.version,
observedVersion: input.file.version,
@@ -173,11 +167,10 @@ export class FileEditorModel {
const content = this.snapshot.content;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
const serializedContent = this.hasBom ? `\uFEFF${content}` : content;
let result: FileWriteResult;
try {
result = await this.session.write({
content: serializedContent,
content,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
@@ -248,11 +241,9 @@ export class FileEditorModel {
return;
}
this.persistedContent = file.content;
this.hasBom = file.hasBom;
this.setSnapshot({
status: "clean",
content: file.content,
lineSeparator: detectLineSeparator(file.content),
modified: false,
version: file.version,
observedVersion: file.version,
@@ -299,15 +290,6 @@ export class FileEditorModel {
}
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function sameVersion(left: FileVersion, right: FileVersion): boolean {
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
return false;
@@ -56,8 +56,7 @@ export function FileEditorView({
update.docChanged &&
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
) {
const { lineSeparator } = values.model.getSnapshot();
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
values.model.edit(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
@@ -78,12 +77,10 @@ export function FileEditorView({
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const document = view.state.toText(snapshot.content);
if (view.state.doc.eq(document)) return;
const head = Math.min(view.state.selection.main.head, document.length);
if (!view || view.state.doc.toString() === snapshot.content) return;
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
+2 -7
View File
@@ -637,13 +637,9 @@ function EditableFilePane({
() => ({
async read(): Promise<FileEditorFile> {
const file = await client.readFile(cwd, path);
const decodedFile = explorerFileFromReadResult(file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
throw new Error("File is no longer text.");
}
if (file.kind !== "text") throw new Error("File is no longer text.");
return {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
content: new TextDecoder().decode(file.bytes),
version: {
status: "ready",
cwd,
@@ -665,7 +661,6 @@ function EditableFilePane({
new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
@@ -1,79 +0,0 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
interface CommitGraphNodeProps {
commit: ClassifiedCheckoutCommit;
isFirst: boolean;
isLast: boolean;
}
export function CommitGraphNode({ commit, isFirst, isLast }: CommitGraphNodeProps) {
const isOnBase = commit.isOnBase;
const railColor = isOnBase ? styles.railBase : styles.railWorkspace;
const markerColor = isOnBase ? styles.markerBase : styles.markerWorkspace;
return (
<View style={styles.container}>
{isFirst && isLast ? null : (
<View
style={[styles.rail, railColor, isFirst && styles.railFirst, isLast && styles.railLast]}
/>
)}
<View
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
style={[styles.marker, markerColor, !commit.isOnRemote && styles.markerRing]}
/>
</View>
);
}
const MARKER_SIZE = 8;
const RAIL_WIDTH = 2;
const styles = StyleSheet.create((theme) => ({
container: {
width: MARKER_SIZE,
alignSelf: "stretch",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
},
rail: {
position: "absolute",
top: -theme.spacing[1] - 1,
bottom: -theme.spacing[1] - 1,
width: RAIL_WIDTH,
},
railFirst: {
top: "50%",
},
railLast: {
bottom: "50%",
},
railBase: {
backgroundColor: theme.colors.foregroundMuted,
},
railWorkspace: {
backgroundColor: theme.colors.accent,
},
marker: {
width: MARKER_SIZE,
height: MARKER_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[2],
zIndex: 1,
},
markerBase: {
backgroundColor: theme.colors.foregroundMuted,
borderColor: theme.colors.foregroundMuted,
},
markerWorkspace: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
markerRing: {
backgroundColor: theme.colors.surface0,
},
}));
@@ -1,34 +1,18 @@
import { memo, useCallback } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { formatTimeAgo } from "@/utils/time";
import { CommitGraphNode } from "./commit-graph-node";
import { dotStyles } from "./shared";
interface CommitRowProps {
commit: ClassifiedCheckoutCommit;
isFirst: boolean;
isLast: boolean;
commit: CheckoutCommit;
now: Date;
onCommitPress: (sha: string) => void;
}
function commitRowPressableStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.row, (Boolean(hovered) || pressed) && styles.rowActive];
}
export const CommitRow = memo(function CommitRow({
commit,
isFirst,
isLast,
now,
onCommitPress,
}: CommitRowProps) {
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
const handlePress = useCallback(() => {
onCommitPress(commit.sha);
}, [commit.sha, onCommitPress]);
@@ -38,17 +22,16 @@ export const CommitRow = memo(function CommitRow({
accessibilityRole="button"
testID={`commit-row-${commit.shortSha}`}
onPress={handlePress}
style={commitRowPressableStyle}
style={styles.row}
>
<CommitGraphNode commit={commit} isFirst={isFirst} isLast={isLast} />
<View style={styles.commitDetails}>
<Text dataSet={CODE_SURFACE_DATASET} style={styles.shortSha} numberOfLines={1}>
{commit.shortSha}
</Text>
<Text style={styles.subject} numberOfLines={1}>
{commit.subject}
</Text>
</View>
<View
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
/>
<Text style={styles.shortSha}>{commit.shortSha}</Text>
<Text style={styles.subject} numberOfLines={1}>
{commit.subject}
</Text>
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
@@ -66,21 +49,10 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
rowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
commitDetails: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
shortSha: {
fontSize: theme.fontSize.xs,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted,
width: 70,
flexShrink: 0,
},
subject: {
@@ -6,7 +6,6 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { CommitRow } from "./commit-row";
interface CommitsSectionProps {
@@ -15,6 +14,8 @@ interface CommitsSectionProps {
onCommitPress: (sha: string) => void;
}
const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"];
function CommitsSectionSkeleton() {
const { t } = useTranslation();
return (
@@ -24,13 +25,15 @@ function CommitsSectionSkeleton() {
style={styles.skeleton}
testID="commits-section-skeleton"
>
<View style={styles.skeletonRow}>
<View style={styles.skeletonDot} />
<View style={styles.skeletonSha} />
<View style={styles.skeletonSubject} />
<View style={styles.skeletonTimestamp} />
<View style={styles.skeletonCaret} />
</View>
{SKELETON_ROW_KEYS.map((key) => (
<View key={key} style={styles.skeletonRow}>
<View style={styles.skeletonDot} />
<View style={styles.skeletonSha} />
<View style={styles.skeletonSubject} />
<View style={styles.skeletonTimestamp} />
<View style={styles.skeletonCaret} />
</View>
))}
</View>
);
}
@@ -55,28 +58,17 @@ function CommitsSectionContent({
if (query.status !== "loaded") {
return <CommitsSectionSkeleton />;
}
const workspaceCommits = query.data.commits.filter((commit) => !commit.isOnBase);
const baseRef = normalizeBranchOptionName(query.data.baseRef) ?? t("workspace.git.diff.base");
if (workspaceCommits.length === 0) {
if (query.data.commits.length === 0) {
return (
<View style={styles.noWorkspaceCommitsRow} testID="commits-section-no-workspace-commits">
<Text style={styles.noWorkspaceCommitsText}>
{t("workspace.git.diff.commits.noneAhead", { baseRef })}
</Text>
</View>
<Text style={styles.emptyRow} testID="commits-section-empty">
{t("workspace.git.diff.commits.empty")}
</Text>
);
}
return (
<View style={styles.list}>
{workspaceCommits.map((commit, index) => (
<CommitRow
key={commit.sha}
commit={commit}
isFirst={index === 0}
isLast={index === workspaceCommits.length - 1}
now={now}
onCommitPress={onCommitPress}
/>
{query.data.commits.map((commit) => (
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
))}
</View>
);
@@ -118,10 +110,7 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
if (query.status === "unsupported") {
return null;
}
const commitCount =
query.status === "loaded"
? query.data.commits.filter((commit) => !commit.isOnBase).length
: null;
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
return (
<View style={styles.container}>
@@ -194,17 +183,12 @@ const styles = StyleSheet.create((theme) => ({
list: {
paddingBottom: theme.spacing[1],
},
noWorkspaceCommitsRow: {
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[2],
},
noWorkspaceCommitsText: {
emptyRow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
errorRow: {
fontSize: theme.fontSize.xs,
@@ -0,0 +1,37 @@
import { StyleSheet } from "react-native-unistyles";
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
export type CheckoutCommitFile = CheckoutCommit["files"][number];
export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void;
export const DOT_SIZE = 8;
// The "on remote" dot is intentionally understated: the local-only ring is the
// state worth noticing (you still have work to push), so the remote fill is
// dimmed toward the background rather than rendered at full-strength green.
const REMOTE_DOT_OPACITY = 0.55;
/**
* A local-only commit is a hollow ring; a commit that has reached the remote
* is a subtle (dimmed) filled green dot.
*/
export const dotStyles = StyleSheet.create((theme) => ({
dotLocal: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.foregroundMuted,
flexShrink: 0,
},
dotRemote: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.statusSuccess,
opacity: REMOTE_DOT_OPACITY,
flexShrink: 0,
},
}));
+4 -17
View File
@@ -1,5 +1,4 @@
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
import invariant from "tiny-invariant";
import { useFetchQuery } from "@/data/query";
import { checkoutCommitsQueryKey } from "@/git/query-keys";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -17,11 +16,7 @@ interface UseCheckoutCommitsQueryOptions {
export interface CheckoutCommitsData {
baseRef: string | null;
commits: ClassifiedCheckoutCommit[];
}
export interface ClassifiedCheckoutCommit extends CheckoutCommit {
isOnBase: boolean;
commits: CheckoutCommit[];
}
export type CheckoutCommitsQueryResult =
@@ -74,13 +69,10 @@ export function useCheckoutCommitsQuery({
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
// Single capability-detection site; downstream reads a clean load-state union.
const capabilityPresent = useSessionStore(
(state) =>
state.sessions[serverId]?.serverInfo?.features?.commitsList === true &&
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
(state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true,
);
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
@@ -92,12 +84,7 @@ export function useCheckoutCommitsQuery({
if (!client) {
throw new Error("Host disconnected");
}
const data = await client.listCheckoutCommits(cwd);
const commits = data.commits.map((commit) => {
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
return { ...commit, isOnBase: commit.isOnBase };
});
return { baseRef: data.baseRef, commits };
return client.listCheckoutCommits(cwd);
},
enabled: queryEnabled,
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
@@ -308,9 +308,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const modelSelectorProviders = snapshotModelSelectorProviders;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isModelSelectionLoading =
resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading;
const isAllModelsLoading = isModelSelectionLoading;
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
const combinedInitialValues = useMemo(
() => combineInitialValues(initialValues, initialServerId),
@@ -569,7 +567,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
() => availableThinkingOptionsRaw ?? [],
[availableThinkingOptionsRaw],
);
const isModelLoading = isModelSelectionLoading;
const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim();
+1 -2
View File
@@ -795,8 +795,7 @@ export const ar: TranslationResources = {
deletedFile: "تم الحذف",
commits: {
title: "الإيداعات",
countLabel: "{{count}} من إيداعات مساحة العمل",
noneAhead: "لا توجد إيداعات متقدمة على {{baseRef}} بعد",
countLabel: "{{count}} من الإيداعات الأخيرة",
fileDiffEmpty: "لا توجد تغييرات لعرضها",
fileDiffError: "تعذّر تحميل فروق الملف",
loading: "جارٍ تحميل الإيداعات…",
+1 -2
View File
@@ -805,8 +805,7 @@ export const en = {
deletedFile: "Deleted",
commits: {
title: "Commits",
countLabel: "{{count}} workspace commits",
noneAhead: "No commits ahead of {{baseRef}} yet",
countLabel: "{{count}} recent commits",
fileDiffEmpty: "No changes to display",
fileDiffError: "Failed to load file diff",
loading: "Loading commits…",
+1 -2
View File
@@ -826,8 +826,7 @@ export const es: TranslationResources = {
deletedFile: "Eliminado",
commits: {
title: "Commits",
countLabel: "{{count}} commits del espacio de trabajo",
noneAhead: "Aún no hay commits por delante de {{baseRef}}",
countLabel: "{{count}} commits recientes",
fileDiffEmpty: "No hay cambios para mostrar",
fileDiffError: "Error al cargar el diff del archivo",
loading: "Cargando commits…",
+1 -2
View File
@@ -825,8 +825,7 @@ export const fr: TranslationResources = {
deletedFile: "Supprimé",
commits: {
title: "Commits",
countLabel: "{{count}} commits de l’espace de travail",
noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment",
countLabel: "{{count}} commits récents",
fileDiffEmpty: "Aucune modification à afficher",
fileDiffError: "Échec du chargement du diff du fichier",
loading: "Chargement des commits…",
+1 -2
View File
@@ -806,8 +806,7 @@ export const ja: TranslationResources = {
deletedFile: "削除済み",
commits: {
title: "コミット",
countLabel: "ワークスペースのコミット数: {{count}}",
noneAhead: "{{baseRef}} より先のコミットはまだありません",
countLabel: "最近のコミット数: {{count}}",
fileDiffEmpty: "表示する変更はありません",
fileDiffError: "ファイル差分の読み込みに失敗しました",
loading: "コミットを読み込み中…",
+1 -2
View File
@@ -817,8 +817,7 @@ export const ptBR: TranslationResources = {
deletedFile: "Excluído",
commits: {
title: "Commits",
countLabel: "{{count}} commits do espaço de trabalho",
noneAhead: "Ainda não há commits à frente de {{baseRef}}",
countLabel: "{{count}} commits recentes",
fileDiffEmpty: "Nenhuma alteração para exibir",
fileDiffError: "Falha ao carregar diff do arquivo",
loading: "Carregando commits…",
+1 -2
View File
@@ -817,8 +817,7 @@ export const ru: TranslationResources = {
deletedFile: "Удалено",
commits: {
title: "Коммиты",
countLabel: "{{count}} коммитов рабочего пространства",
noneAhead: "Коммитов впереди {{baseRef}} пока нет",
countLabel: "{{count}} последних коммитов",
fileDiffEmpty: "Нет изменений для отображения",
fileDiffError: "Не удалось загрузить различия файла",
loading: "Загрузка коммитов…",
+1 -2
View File
@@ -787,8 +787,7 @@ export const zhCN: TranslationResources = {
deletedFile: "已删除",
commits: {
title: "提交",
countLabel: "{{count}} 个工作区提交",
noneAhead: "尚无领先于 {{baseRef}} 的提交",
countLabel: "最近 {{count}} 个提交",
fileDiffEmpty: "没有可显示的更改",
fileDiffError: "加载文件差异失败",
loading: "正在加载提交…",
+15
View File
@@ -24,6 +24,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons";
import {
@@ -1528,6 +1529,19 @@ function ActiveAgentComposer({
[insets.bottom, composerKeyboardStyle],
);
const composerFooter = useMemo(
() =>
isCompactComposerLayout ? (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="footer"
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompactComposerLayout, serverId, agentId],
);
return (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<SubagentsTrack
@@ -1560,6 +1574,7 @@ function ActiveAgentComposer({
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
@@ -260,25 +260,6 @@ describe("combined model selector data", () => {
).toBe("Default");
});
it("distinguishes a loading selection from a resolved empty selection", () => {
expect(
resolveSelectedModelLabel({
providers: [],
selectedProvider: "",
selectedModel: "",
isLoading: true,
}),
).toBe("Loading...");
expect(
resolveSelectedModelLabel({
providers: [],
selectedProvider: "",
selectedModel: "",
isLoading: false,
}),
).toBe("Select model");
});
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
@@ -157,9 +157,7 @@ export function resolveSelectedModelLabel(input: {
}): string {
const selectedProvider = input.selectedProvider.trim();
if (!selectedProvider) {
return input.isLoading
? i18n.t("providerSelection.loading")
: i18n.t("providerSelection.selectModel");
return i18n.t("providerSelection.selectModel");
}
const provider = input.providers.find((entry) => entry.id === selectedProvider);
@@ -1096,7 +1096,7 @@ describe("resolveAgentForm", () => {
});
describe("RESET", () => {
it("keeps form values but marks them unresolved for the next open", () => {
it("resets userModified flags while keeping form state", () => {
const state = makeState(
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
{ provider: true, modeId: true, model: true },
@@ -1106,7 +1106,7 @@ describe("resolveAgentForm", () => {
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
expect(next.form).toEqual(state.form);
expect(next.resolution.status).toBe("pending");
expect(next.resolution.status).toBe("completed");
});
});
@@ -56,8 +56,8 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = {
workingDir: false,
};
export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" };
export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" };
export const INITIAL_AGENT_FORM_RESOLUTION = PENDING_AGENT_FORM_RESOLUTION;
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
@@ -12,6 +12,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
@@ -1381,7 +1382,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
open={project.openState}
onOpenChange={project.onOpenChange}
desktopPlacement="bottom-start"
desktopMinWidth={360}
anchorRef={project.anchorRef}
emptyText="No projects available."
renderOption={project.renderOption}
@@ -1402,7 +1402,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
searchable={false}
title="Host"
desktopPlacement="bottom-start"
desktopMinWidth={200}
hostOptionTestID={newWorkspaceHostOptionTestID}
>
<Pressable
@@ -2110,6 +2109,13 @@ export function NewWorkspaceScreen({
},
});
const composerFooter = useMemo(
() =>
agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null,
[agentControlsWithDisabled],
);
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
@@ -2148,6 +2154,7 @@ export function NewWorkspaceScreen({
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
footer={composerFooter}
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
@@ -3536,7 +3536,7 @@ function WorkspaceScreenContent({
tooltipLabel={t("workspace.tabs.explorer.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={[styles.compactHeaderActionButton, styles.explorerPanelButton]}
style={styles.compactHeaderActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
@@ -3959,11 +3959,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
explorerPanelButton: {
// The 32px trigger and 16px panel glyph leave 8px more trailing space than
// the 22px split-pane trigger and its 14px glyph in the row below.
marginRight: -theme.spacing[2],
},
compactHeaderMenuCluster: {
flexDirection: "row",
alignItems: "center",
@@ -3982,9 +3977,6 @@ const styles = StyleSheet.create((theme) => ({
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
borderRadius: theme.borderRadius.md,
// Match the painted right edge of the trailing split-pane glyph below. The
// two header rows intentionally use different control sizes and padding.
marginRight: -7,
},
sourceControlButtonWithStats: {
paddingHorizontal: theme.spacing[3],
-2
View File
@@ -278,8 +278,6 @@ export interface ExplorerFile {
kind: ExplorerFileKind;
encoding: ExplorerEncoding;
content?: string;
// TextDecoder removes a leading UTF-8 BOM; retain this bit so file writes can restore it.
hasBom: boolean;
mimeType?: string;
size: number;
modifiedAt: string;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/server": "0.2.0-beta.4",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/server": "0.2.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -35,7 +35,6 @@ describe("checkout.commits.list schemas", () => {
authorName: "Ada",
authorDate: "2026-06-13T10:00:00.000Z",
isOnRemote: true,
isOnBase: false,
files: [
{ path: "src/a.ts", additions: 10, deletions: 2, status: "modified" },
{ path: "src/b.ts", additions: 5, deletions: 0, status: "added" },
@@ -48,7 +47,6 @@ describe("checkout.commits.list schemas", () => {
authorName: "Ada",
authorDate: "2026-06-13T11:00:00.000Z",
isOnRemote: false,
isOnBase: true,
files: [{ path: "src/c.ts", additions: 1, deletions: 1 }],
},
],
@@ -63,37 +61,10 @@ describe("checkout.commits.list schemas", () => {
expect(parsed.payload).toEqual(payload);
expect(parsed.payload.commits[0]?.isOnRemote).toBe(true);
expect(parsed.payload.commits[0]?.isOnBase).toBe(false);
expect(parsed.payload.commits[1]?.isOnRemote).toBe(false);
expect(parsed.payload.commits[1]?.isOnBase).toBe(true);
expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined();
});
test("still parses commits from hosts without base classification", () => {
const parsed = CheckoutCommitsListResponseSchema.parse({
type: "checkout.commits.list.response",
payload: {
cwd: "/tmp/repo",
baseRef: "main",
commits: [
{
sha: "1111111111111111111111111111111111111111",
shortSha: "1111111",
subject: "Legacy commit",
authorName: "Ada",
authorDate: "2026-06-13T10:00:00.000Z",
isOnRemote: true,
files: [],
},
],
error: null,
requestId: "request-commits",
},
});
expect(parsed.payload.commits[0]?.isOnBase).toBeUndefined();
});
test("accepts a null baseRef and an error payload", () => {
const payload = {
cwd: "/tmp/repo",
@@ -136,17 +107,16 @@ describe("checkout.commits.list schemas", () => {
).toMatchObject({ type: "checkout.commits.list.response" });
});
test("accepts the commit history server_info feature flags", () => {
test("accepts the commitsList server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "srv_test",
features: {
commitsList: true,
commitBaseClassification: true,
},
}).features,
).toEqual({ commitsList: true, commitBaseClassification: true });
).toEqual({ commitsList: true });
});
test("still parses server_info without the commitsList feature flag", () => {
-4
View File
@@ -1732,8 +1732,6 @@ const CheckoutCommitSchema = z.object({
authorName: z.string(),
authorDate: z.string(), // ISO 8601
isOnRemote: z.boolean(), // false = local-only (unpushed)
// COMPAT(commitBaseClassification): added in v0.2.0, remove optional after 2027-01-23.
isOnBase: z.boolean().optional(),
files: z.array(CheckoutCommitFileSchema),
});
@@ -2779,8 +2777,6 @@ export const ServerInfoStatusPayloadSchema = z
projectCreateDirectory: z.boolean().optional(),
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: z.boolean().optional(),
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
commitBaseClassification: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.2.0-beta.4",
"version": "0.2.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -67,10 +67,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/highlight": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/highlight": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -1,82 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { AgentStorage } from "./agent-storage.js";
import type {
AgentClient,
AgentLaunchContext,
AgentPersistenceHandle,
AgentResumeSessionOptions,
AgentSession,
AgentSessionConfig,
} from "./agent-sdk-types.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
test("loads archived records for history and active records with the interactive default", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const baseClient = createTestAgentClients().codex;
if (!baseClient) {
throw new Error("expected Codex test client");
}
const resumeOptions: Array<AgentResumeSessionOptions | undefined> = [];
const client: AgentClient = {
provider: baseClient.provider,
capabilities: baseClient.capabilities,
createSession: async (
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> => await baseClient.createSession(config, launchContext),
resumeSession: async (
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> => {
resumeOptions.push(options);
return await baseClient.resumeSession(handle, overrides, launchContext);
},
fetchCatalog: async (options) => await baseClient.fetchCatalog(options),
isAvailable: async () => await baseClient.isAvailable(),
};
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
});
const archivedId = "00000000-0000-4000-8000-000000000301";
const activeId = "00000000-0000-4000-8000-000000000302";
try {
const archived = await manager.createAgent({ provider: "codex", cwd: root }, archivedId, {
workspaceId: "workspace-archived",
});
await manager.archiveAgent(archived.id);
const active = await manager.createAgent({ provider: "codex", cwd: root }, activeId, {
workspaceId: "workspace-active",
});
await manager.closeAgent(active.id);
await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger });
await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger });
expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]);
} finally {
await Promise.all([
manager.closeAgent(archivedId).catch(() => undefined),
manager.closeAgent(activeId).catch(() => undefined),
]);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
@@ -111,7 +111,6 @@ export async function ensureAgentLoaded(
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
record.archivedAt ? { purpose: "history" } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
@@ -19,7 +19,6 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
@@ -385,7 +384,6 @@ export type ManagedAgent =
export interface AgentMetricsSnapshot {
total: number;
subscriptionCount: number;
byLifecycle: Record<string, number>;
withActiveForegroundTurn: number;
timelineStats: {
@@ -727,7 +725,6 @@ export class AgentManager {
return {
total: this.agents.size,
subscriptionCount: this.subscribers.size,
byLifecycle,
withActiveForegroundTurn,
timelineStats: {
@@ -1081,10 +1078,9 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions),
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options),
);
}
@@ -1100,7 +1096,6 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(
@@ -1127,12 +1122,7 @@ export class AgentManager {
}
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(
handle,
providerLaunchConfig,
launchContext,
resumeOptions,
);
const session = await client.resumeSession(handle, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
persistence: handle,
@@ -602,12 +602,6 @@ export interface AgentCreateSessionOptions {
persistSession?: boolean;
}
/** Runtime-only intent for a persisted-session resume. Never persist this option. */
export interface AgentResumeSessionOptions {
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
purpose?: "interactive" | "history";
}
/**
* Returned by respondToPermission when the permission resolution requires
* a follow-up turn (e.g. Codex plan approval → implementation).
@@ -694,7 +688,6 @@ export interface AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession>;
/**
* Discover models and modes together. Implementations may use one upstream
@@ -408,7 +408,7 @@ function wrapClientProvider(
launchContext,
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
resumeSession: async (handle, overrides, launchContext) =>
wrapSessionProvider(
provider,
await inner.resumeSession(
@@ -423,7 +423,6 @@ function wrapClientProvider(
}
: undefined,
launchContext,
options,
),
),
fetchCatalog: async (options) => {
@@ -2150,14 +2150,6 @@ describe("ACPAgentSession", () => {
content: { type: "text", text: "lo" },
} as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-2",
content: { type: "text", text: "" },
} as SessionUpdate,
});
const timeline = events
.filter((event) => event.type === "timeline")
@@ -2169,6 +2161,7 @@ describe("ACPAgentSession", () => {
{ type: "assistant_message", text: " How are you?", messageId: "assistant-1" },
{ type: "reasoning", text: "Thinking" },
{ type: "reasoning", text: " more" },
{ type: "user_message", text: "hel", messageId: "user-1" },
{ type: "user_message", text: "hello", messageId: "user-1" },
]);
});
@@ -2535,102 +2528,6 @@ describe("ACPAgentSession", () => {
]);
});
test("startTurn dedupes a provider-owned user echo streamed as text and image chunks", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => {
events.push(event);
});
await session.startTurn(
[
{ type: "text", text: "hey" },
{ type: "image", data: "AA==", mimeType: "image/png" },
],
{ clientMessageId: "msg-client-1" },
);
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg-provider-1",
content: { type: "text", text: "hey" },
} as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg-provider-1",
content: { type: "image", data: "AA==", mimeType: "image/png" },
} as SessionUpdate,
});
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
).toEqual([
{
type: "timeline",
provider: session.provider,
item: {
type: "user_message",
text: "hey",
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
},
turnId: expect.any(String),
},
]);
});
test("startTurn keeps an image-only provider echo when no canonical user message was emitted", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => {
events.push(event);
});
await session.startTurn([{ type: "image", data: "AA==", mimeType: "image/png" }], {
clientMessageId: "msg-client-1",
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "AA==", mimeType: "image/png" },
} as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-1",
content: { type: "text", text: "I see it" },
} as SessionUpdate,
});
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
).toEqual([
{
type: "timeline",
provider: session.provider,
item: { type: "user_message", text: "[image]" },
turnId: expect.any(String),
},
]);
});
test("startTurn converts background prompt rejections into turn_failed events", async () => {
const session = createSession();
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
@@ -2664,49 +2561,6 @@ describe("ACPAgentSession", () => {
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
});
test("flushes an image-only provider echo before a rejected turn finishes", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
let rejectPrompt!: (error: Error) => void;
const prompt = vi.fn(
() =>
new Promise<PromptResponse>((_, reject) => {
rejectPrompt = reject;
}),
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn([
{ type: "image", data: "AA==", mimeType: "image/png" },
]);
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "AA==", mimeType: "image/png" },
} as SessionUpdate,
});
rejectPrompt(new Error("prompt failed"));
await Promise.resolve();
await Promise.resolve();
expect(
events.filter((event) => event.type === "timeline" || event.type === "turn_failed"),
).toEqual([
{
type: "timeline",
provider: session.provider,
item: { type: "user_message", text: "[image]" },
turnId,
},
expect.objectContaining({ type: "turn_failed", turnId, error: "prompt failed" }),
]);
});
test("startTurn preserves JSON-RPC error details from a real ACP prompt response", async () => {
const session = createSession();
const clientToAgent = new TransformStream();
@@ -2809,32 +2663,6 @@ describe("ACPAgentSession close() tree-kill", () => {
vi.restoreAllMocks();
});
test("close() flushes a buffered provider-owned user message before unsubscribing", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPCloseInternals>(session).sessionId = "session-1";
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "AA==", mimeType: "image/png" },
} as SessionUpdate,
});
expect(events).toEqual([]);
await session.close();
expect(events).toEqual([
{
type: "timeline",
provider: session.provider,
item: { type: "user_message", text: "[image]" },
},
]);
});
test("close() terminates the main child process via the process tree", async () => {
const terminator = new FakeTerminator();
const session = createSession(terminator.terminate);
@@ -2914,81 +2742,6 @@ describe("ACPAgentSession close() tree-kill", () => {
});
});
describe("ACPAgentSession initialization cleanup", () => {
test("terminates the ACP process when session/new fails", async () => {
const terminator = new FakeTerminator();
const child = createProbeChildStub();
class FailingNewSession extends ACPAgentSession {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child,
connection: {
newSession: vi.fn().mockRejectedValue(new Error("session/new failed")),
} as unknown as ClientSideConnection,
initialize: { agentCapabilities: {} },
};
}
}
const session = new FailingNewSession(
{ provider: "copilot", cwd: "/tmp/paseo-acp-test" },
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
},
terminateProcess: terminator.terminate,
},
);
await expect(session.initializeNewSession()).rejects.toThrow("session/new failed");
expect(terminator.terminated).toContain(child);
});
test("terminates the ACP process when session/load fails", async () => {
const terminator = new FakeTerminator();
const child = createProbeChildStub();
class FailingLoadSession extends ACPAgentSession {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child,
connection: {
loadSession: vi.fn().mockRejectedValue(new Error("session/load failed")),
} as unknown as ClientSideConnection,
initialize: { agentCapabilities: { loadSession: true } },
};
}
}
const session = new FailingLoadSession(
{ provider: "cursor", cwd: "/tmp/paseo-acp-test" },
{
provider: "cursor",
logger: createTestLogger(),
defaultCommand: ["cursor-agent", "acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
},
handle: { provider: "cursor", sessionId: "session-1" },
terminateProcess: terminator.terminate,
},
);
await expect(session.initializeResumedSession()).rejects.toThrow("session/load failed");
expect(terminator.terminated).toContain(child);
});
});
describe("ACPAgentClient probe cleanup", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -3156,68 +2909,6 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () =
]);
});
test("coalesces an ID-less text and image user message during loadSession replay", async () => {
let session!: ACPAgentSession;
const loadSession = async () => {
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "hey" },
} as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "AA==", mimeType: "image/png" },
} as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-replay-1",
content: { type: "text", text: "Hello" },
} as SessionUpdate,
});
return {
sessionId: "session-1",
modes: null,
models: null,
configOptions: [],
};
};
({ session } = makeTestSession({
capabilities: { loadSession: true },
handle: { sessionId: "session-1", provider: "test-acp" },
loadSession,
}));
await session.initializeResumedSession();
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: session.provider,
item: { type: "user_message", text: "hey[image]" },
},
{
type: "timeline",
provider: session.provider,
item: {
type: "assistant_message",
text: "Hello",
messageId: "assistant-replay-1",
},
},
]);
});
test("assigns stable fallback IDs to ID-less assistant messages during loadSession replay", async () => {
let session!: ACPAgentSession;
const loadSession = async () => {
@@ -467,9 +467,14 @@ interface PendingPermission {
turnId: string | null;
}
interface PendingUserMessage {
interface MessageAssemblyState {
text: string;
messageId?: string;
}
interface SubmittedUserMessageEcho {
messageId: string;
text: string;
turnId: string;
}
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
@@ -1297,8 +1302,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
private pendingUserMessage: PendingUserMessage | null = null;
private submittedUserMessageTurnId: string | null = null;
private readonly messageAssemblies = new Map<string, MessageAssemblyState>();
private readonly submittedUserMessageIds = new Set<string>();
private activeSubmittedUserMessage: SubmittedUserMessageEcho | null = null;
private readonly toolCalls = new Map<string, ACPToolSnapshot>();
private readonly terminalEntries = new Map<string, TerminalEntry>();
private readonly persistedHistory: AgentTimelineItem[] = [];
@@ -1370,25 +1376,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async initializeNewSession(): Promise<void> {
try {
const spawned = await this.spawnProcess();
this.child = spawned.child;
this.connection = spawned.connection;
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
const spawned = await this.spawnProcess();
this.child = spawned.child;
this.connection = spawned.connection;
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
const response = await this.runACPRequest(() =>
this.connection!.newSession({
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.sessionId = response.sessionId;
this.bootstrapThreadEventPending = true;
this.applySessionState(response);
await this.applyConfiguredOverrides();
} catch (error) {
await this.closeAfterInitializationFailure(error);
}
const response = await this.runACPRequest(() =>
this.connection!.newSession({
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.sessionId = response.sessionId;
this.bootstrapThreadEventPending = true;
this.applySessionState(response);
await this.applyConfiguredOverrides();
}
/**
@@ -1399,62 +1401,45 @@ export class ACPAgentSession implements AgentSession, ACPClient {
* from these calls regardless of capabilities.
*/
async initializeResumedSession(): Promise<void> {
try {
const handle = this.initialHandle;
if (!handle) {
throw new Error("Resume requested without persistence handle");
}
const spawned = await this.spawnProcess();
this.child = spawned.child;
this.connection = spawned.connection;
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
this.sessionId = handle.sessionId;
this.bootstrapThreadEventPending = true;
const sessionCapabilities = this.agentCapabilities?.sessionCapabilities;
if (this.agentCapabilities?.loadSession) {
this.replayingHistory = true;
const response = await this.runACPRequest(() =>
this.connection!.loadSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.deliverTranslatedEvents(this.flushPendingUserMessage());
this.replayingHistory = false;
this.historyPending = this.persistedHistory.length > 0;
this.applySessionState(response);
} else if (sessionCapabilities?.resume) {
const response = await this.runACPRequest(() =>
this.connection!.unstable_resumeSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.applySessionState(response);
} else {
throw new Error(`${this.provider} does not support ACP session resume`);
}
await this.applyConfiguredOverrides();
} catch (error) {
await this.closeAfterInitializationFailure(error);
const handle = this.initialHandle;
if (!handle) {
throw new Error("Resume requested without persistence handle");
}
}
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
try {
await this.close();
} catch (closeError) {
this.logger.warn(
{ err: closeError, initializationError: error },
"Failed to close ACP process after session initialization failure",
const spawned = await this.spawnProcess();
this.child = spawned.child;
this.connection = spawned.connection;
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
this.sessionId = handle.sessionId;
this.bootstrapThreadEventPending = true;
const sessionCapabilities = this.agentCapabilities?.sessionCapabilities;
if (this.agentCapabilities?.loadSession) {
this.replayingHistory = true;
const response = await this.runACPRequest(() =>
this.connection!.loadSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.replayingHistory = false;
this.historyPending = this.persistedHistory.length > 0;
this.applySessionState(response);
} else if (sessionCapabilities?.resume) {
const response = await this.runACPRequest(() =>
this.connection!.unstable_resumeSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.applySessionState(response);
} else {
throw new Error(`${this.provider} does not support ACP session resume`);
}
throw error;
await this.applyConfiguredOverrides();
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -1488,12 +1473,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("A foreground turn is already active");
}
this.deliverTranslatedEvents(this.flushPendingUserMessage());
const turnId = randomUUID();
const messageId = options?.clientMessageId ?? randomUUID();
this.activeForegroundTurnId = turnId;
this.fallbackAssistantMessageId = null;
this.submittedUserMessageTurnId = null;
this.activeSubmittedUserMessage = null;
this.emitBootstrapThreadEvent();
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.emitSubmittedUserMessage(prompt, messageId, turnId, options?.clientMessageId);
@@ -2039,7 +2023,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
this.closed = true;
this.deliverTranslatedEvents(this.flushPendingUserMessage());
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) {
@@ -2138,10 +2121,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
},
"provider.acp.parsed_event",
);
this.deliverTranslatedEvents(events);
}
private deliverTranslatedEvents(events: AgentStreamEvent[]): void {
if (this.replayingHistory) {
for (const event of events) {
if (event.type === "timeline") {
@@ -2356,10 +2335,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
{ logger: this.logger, provider: this.provider },
);
const connection = new ClientSideConnection(() => this, stream);
// Take ownership before initialize so the outer initialization guard can
// close the process even when the ACP handshake itself rejects.
this.child = child;
this.connection = connection;
const initialize = await this.runACPRequest(() =>
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
@@ -2468,43 +2443,45 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
if (update.sessionUpdate === "user_message_chunk") {
return this.handleUserMessageChunk(update);
}
const pendingUserEvents = this.flushPendingUserMessage();
switch (update.sessionUpdate) {
case "user_message_chunk": {
this.fallbackAssistantMessageId = null;
const item = this.createMessageTimelineItem("user_message", update);
if (!item) {
return [];
}
if (item.type !== "user_message") {
return [this.wrapTimeline(item)];
}
if (this.isSubmittedUserMessageEcho(item)) {
return [];
}
return [this.wrapTimeline(item)];
}
case "agent_message_chunk": {
const item = this.createMessageTimelineItem("assistant_message", update);
return item ? [...pendingUserEvents, this.wrapTimeline(item)] : pendingUserEvents;
return item ? [this.wrapTimeline(item)] : [];
}
case "agent_thought_chunk": {
this.fallbackAssistantMessageId = null;
const item = this.createMessageTimelineItem("reasoning", update);
return item ? [...pendingUserEvents, this.wrapTimeline(item)] : pendingUserEvents;
return item ? [this.wrapTimeline(item)] : [];
}
case "tool_call":
this.fallbackAssistantMessageId = null;
return [
...pendingUserEvents,
...this.handleToolCallUpdate(update.toolCallId, update, undefined),
];
return this.handleToolCallUpdate(update.toolCallId, update, undefined);
case "tool_call_update":
return [
...pendingUserEvents,
...this.handleToolCallUpdate(
update.toolCallId,
update,
this.toolCalls.get(update.toolCallId),
),
];
return this.handleToolCallUpdate(
update.toolCallId,
update,
this.toolCalls.get(update.toolCallId),
);
case "plan":
this.fallbackAssistantMessageId = null;
return [...pendingUserEvents, this.wrapTimeline(mapPlanToTimeline(update))];
return [this.wrapTimeline(mapPlanToTimeline(update))];
case "current_mode_update":
this.handleCurrentModeUpdate(update);
return [
...pendingUserEvents,
{
type: "mode_changed",
provider: this.provider,
@@ -2513,13 +2490,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
},
];
case "config_option_update":
return [...pendingUserEvents, ...this.handleConfigOptionUpdate(update)];
return this.handleConfigOptionUpdate(update);
case "session_info_update":
this.handleSessionInfoUpdate(update);
return pendingUserEvents;
return [];
case "usage_update":
this.handleUsageUpdate(update);
return pendingUserEvents;
return [];
case "available_commands_update":
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
@@ -2528,60 +2505,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
kind: "command",
}));
this.settleCommandsReady();
return pendingUserEvents;
return [];
default:
return pendingUserEvents;
return [];
}
}
private handleUserMessageChunk(
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
): AgentStreamEvent[] {
this.fallbackAssistantMessageId = null;
if (
this.activeForegroundTurnId &&
this.submittedUserMessageTurnId === this.activeForegroundTurnId
) {
return [];
}
const chunkText = contentBlockToText(update.content);
if (!chunkText) {
return [];
}
const messageId = update.messageId ?? undefined;
const pending = this.pendingUserMessage;
const startsNewMessage = Boolean(
pending?.messageId && messageId && pending.messageId !== messageId,
);
const events = startsNewMessage ? this.flushPendingUserMessage() : [];
this.pendingUserMessage ??= {
text: "",
...(messageId ? { messageId } : {}),
};
if (!this.pendingUserMessage.messageId && messageId) {
this.pendingUserMessage.messageId = messageId;
}
this.pendingUserMessage.text += chunkText;
return events;
}
private flushPendingUserMessage(): AgentStreamEvent[] {
const pending = this.pendingUserMessage;
if (!pending) {
return [];
}
this.pendingUserMessage = null;
return [
this.wrapTimeline({
type: "user_message",
text: pending.text,
...(pending.messageId ? { messageId: pending.messageId } : {}),
}),
];
}
private handleToolCallUpdate(
toolCallId: string,
update: ToolCall | ToolCallUpdate,
@@ -2596,12 +2525,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private createMessageTimelineItem(
type: "assistant_message" | "reasoning",
type: "user_message" | "assistant_message" | "reasoning",
update: Extract<
SessionUpdate,
{ sessionUpdate: "agent_message_chunk" | "agent_thought_chunk" }
{ sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" }
>,
):
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId: string }
| { type: "reasoning"; text: string }
| null {
@@ -2609,6 +2539,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!chunkText) {
return null;
}
const key = this.messageAssemblyKey(type, update.messageId);
const state = this.messageAssemblies.get(key) ?? { text: "" };
state.text += chunkText;
this.messageAssemblies.set(key, state);
if (type === "user_message") {
return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined };
}
if (type === "assistant_message") {
return {
type: "assistant_message",
@@ -2628,6 +2566,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.fallbackAssistantMessageId;
}
private messageAssemblyKey(
type: "user_message" | "assistant_message" | "reasoning",
messageId: string | null | undefined,
): string {
const fallbackId =
type === "user_message" ? (this.activeForegroundTurnId ?? "default") : "default";
return `${type}:${messageId ?? fallbackId}`;
}
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = this.transformModeId(update.currentModeId);
}
@@ -2746,7 +2693,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (text.trim().length === 0) {
return;
}
this.submittedUserMessageTurnId = turnId;
this.submittedUserMessageIds.add(messageId);
this.activeSubmittedUserMessage = { messageId, text, turnId };
this.pushEvent({
type: "timeline",
provider: this.provider,
@@ -2777,15 +2725,29 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private finishTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
this.submittedUserMessageTurnId = null;
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
this.activeSubmittedUserMessage = null;
}
this.pushEvent(event);
}
private isSubmittedUserMessageEcho(
item: Extract<AgentTimelineItem, { type: "user_message" }>,
): boolean {
const active = this.activeSubmittedUserMessage;
if (!active || active.turnId !== this.activeForegroundTurnId) {
return false;
}
if (item.messageId) {
if (this.submittedUserMessageIds.has(item.messageId)) {
return true;
}
}
return active.text.startsWith(item.text);
}
private emitBootstrapThreadEvent(): void {
if (!this.bootstrapThreadEventPending || !this.sessionId) {
return;
@@ -106,37 +106,6 @@ function createSession(
return session;
}
function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
return provider;
}
function archivedThreadHandle() {
return {
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
};
}
function archivedThreadErrorMessage(threadId: string): string {
return (
`session ${threadId} is archived. ` +
`Run \`codex unarchive ${threadId}\` to unarchive it first.`
);
}
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return castInternals<CodexSessionTestAccess>(session);
}
@@ -902,66 +871,6 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("loads archived Codex history without resuming the native thread", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id")));
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.resumeSession(archivedThreadHandle(), undefined, undefined, {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]);
await session.close();
appServer.assertNoErrors();
});
test("closes Codex app-server when an interactive resume fails", async () => {
const appServer = createFakeCodexAppServer({
"thread/resume": () =>
Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))),
});
const killSpy = vi.spyOn(appServer.child, "kill");
const provider = createProviderWithFakeAppServer(appServer);
await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow(
archivedThreadErrorMessage("archived-thread-id"),
);
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
appServer.assertNoErrors();
});
test("closes Codex app-server when archived history hydration fails", async () => {
const appServer = createFakeCodexAppServer({
"thread/resume": () =>
Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))),
"thread/read": () => Promise.reject(new Error("thread history is unavailable")),
});
const killSpy = vi.spyOn(appServer.child, "kill");
const provider = createProviderWithFakeAppServer(appServer);
await expect(
provider.resumeSession(archivedThreadHandle(), undefined, undefined, { purpose: "history" }),
).rejects.toThrow("thread history is unavailable");
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
appServer.assertNoErrors();
});
test("unarchives a persisted Codex thread through app-server", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
@@ -1204,7 +1113,12 @@ describe("Codex app-server provider", () => {
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
@@ -6,7 +6,6 @@ import {
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
type AgentModelDefinition,
type McpServerConfig,
@@ -111,14 +110,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isArchivedCodexThreadResumeError(error: unknown, threadId: string): boolean {
if (!(error instanceof Error)) return false;
const expectedMessage =
`session ${threadId} is archived. ` +
`Run \`codex unarchive ${threadId}\` to unarchive it first.`;
return error.message === expectedMessage;
}
function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(`no archived rollout found for thread id ${threadId}`);
@@ -3183,7 +3174,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly goalsEnabled: boolean = false,
private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
) {
this.logger = logger.child({
module: "agent",
@@ -3230,32 +3220,18 @@ export class CodexAppServerAgentSession implements AgentSession {
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
try {
await this.client.request("initialize", buildCodexAppServerInitializeParams());
this.client.notify("initialized", {});
await this.client.request("initialize", buildCodexAppServerInitializeParams());
this.client.notify("initialized", {});
await this.loadCollaborationModes();
await this.loadSkills();
await this.loadCollaborationModes();
await this.loadSkills();
if (this.currentThreadId) {
await this.ensureThreadLoaded({
allowArchivedHistory: this.initialResumePurpose === "history",
});
await this.loadPersistedHistory();
}
this.connected = true;
} catch (error) {
try {
await this.close();
} catch (closeError) {
this.logger.warn(
{ err: closeError, connectError: error },
"Failed to close Codex app-server after connection failure",
);
}
throw error;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.loadPersistedHistory();
}
this.connected = true;
}
private traceContext(): CodexAppServerTraceContext {
@@ -3561,9 +3537,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async ensureThreadLoaded(
options: { allowArchivedHistory?: boolean } = {},
): Promise<void> {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
try {
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
@@ -3587,16 +3561,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
if (
options.allowArchivedHistory === true &&
isArchivedCodexThreadResumeError(error, threadId)
) {
this.logger.info(
{ threadId },
"Loading archived Codex thread history without resuming the native session",
);
return;
}
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
@@ -6339,7 +6303,6 @@ export class CodexAppServerAgentClient implements AgentClient {
handle: { sessionId: string; metadata?: Record<string, unknown> },
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = {
@@ -6361,7 +6324,6 @@ export class CodexAppServerAgentClient implements AgentClient {
goalsEnabled,
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
);
await session.connect();
return session;
@@ -260,23 +260,6 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("omits live custom messages when display is false", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptAfterExtensionNotice("hello OMP", "model turn completed", false),
).resolves.toMatchObject({ finalText: expect.stringContaining("model turn completed") });
expect(omp.timeline()).toEqual([
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
{
type: "assistant_message",
text: "model turn completed",
messageId: "omp-assistant-1",
},
]);
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -66,7 +66,6 @@ import {
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
@@ -2065,17 +2064,15 @@ export class OmpAgentSession implements AgentSession {
return;
}
if (event.message.role === "custom") {
if (shouldDisplayOmpCustomMessage(event.message)) {
const text = getUserMessageText(event.message.content);
if (text) {
const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text);
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: advisorItem ?? { type: "assistant_message", text },
});
}
const text = getUserMessageText(event.message.content);
if (text) {
const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text);
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: advisorItem ?? { type: "assistant_message", text },
});
}
if (!this.activeTurnHasUserMessage) {
this.completeTurn(turnId, []);
@@ -1,7 +0,0 @@
import type { OmpAgentMessage } from "./rpc-types.js";
type OmpCustomMessage = Extract<OmpAgentMessage, { role: "custom" }>;
export function shouldDisplayOmpCustomMessage(message: OmpCustomMessage): boolean {
return Reflect.get(message, "display") !== false;
}
@@ -209,54 +209,6 @@ describe("OMP history mapper", () => {
]);
});
test("omits replayed custom messages only when display is false", async () => {
await expect(
collectHistory(
[
{ role: "user", content: "first prompt" },
{ role: "custom", content: "hidden reminder", display: false },
{ role: "custom", content: "visible explicit custom", display: true },
{ role: "custom", content: "visible legacy custom" },
{
role: "assistant",
content: [{ type: "text", text: "assistant reply" }],
responseId: "assistant-history",
},
],
[{ id: "entry-user-1", text: "first prompt" }],
),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "first prompt",
messageId: "entry-user-1",
},
},
{
type: "timeline",
provider: "omp",
item: { type: "assistant_message", text: "visible explicit custom" },
},
{
type: "timeline",
provider: "omp",
item: { type: "assistant_message", text: "visible legacy custom" },
},
{
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: "assistant reply",
messageId: "assistant-history",
},
},
]);
});
test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => {
await expect(
collectHistory([
@@ -1,6 +1,5 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
import {
extractTextFromToolResult,
mapToolDetail,
@@ -118,9 +117,6 @@ export class OmpHistoryMapper {
private mapCustomMessage(
message: Extract<OmpAgentMessage, { role: "custom" }>,
): AgentStreamEvent[] {
if (!shouldDisplayOmpCustomMessage(message)) {
return [];
}
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(message, text, this.provider) : null;
if (mappedEvent) {
@@ -201,25 +201,16 @@ export class OmpHarness {
return { completion };
}
async runPromptAfterExtensionNotice(
input: string,
output: string,
display?: boolean,
): Promise<unknown> {
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
const message = {
role: "custom" as const,
content: "extension inventory changed",
...(display === undefined ? {} : { display }),
};
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.emit({ type: "message_end", message });
runtime.finishTurn(message);
runtime.acceptCustomMessage("extension inventory changed");
runtime.finishTurn({ role: "custom", content: "extension inventory changed" });
runtime.beginTurn();
runtime.streamAssistantText(output);
runtime.finishTurn();
@@ -1,9 +1,8 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
import { OmpAgentMessageSchema, OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js";
import { OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
describe("OMP 17 RPC compatibility", () => {
test("parses source-attributed command updates", () => {
@@ -17,20 +16,6 @@ describe("OMP 17 RPC compatibility", () => {
]);
});
test("keeps non-false custom display metadata backward compatible", () => {
const message = OmpAgentMessageSchema.parse({
role: "custom",
content: "visible custom message",
display: null,
});
expect(message).toMatchObject({ display: null });
if (message.role !== "custom") {
throw new Error("Expected a custom OMP message");
}
expect(shouldDisplayOmpCustomMessage(message)).toBe(true);
});
test("maps subscribed custom tool events without assuming built-in names", () => {
const event = {
type: "tool_execution_start",
@@ -2728,65 +2728,6 @@ describe("OpenCode provider subagent contract", () => {
await parent.close();
}
});
test("does not start a new autonomous turn when post-turn user message updates arrive for an already emitted message", async () => {
const { parent, openCode } = await createParentSession("ses_parent_post_turn");
openCode.sessionPromptAsyncEvents = [
...userMessageEvents({
sessionId: "ses_parent_post_turn",
messageId: "msg_user_1",
text: "Hello OpenCode",
}),
...assistantTurnEvents({
sessionId: "ses_parent_post_turn",
text: "Response from OpenCode",
}),
];
const events: AgentStreamEvent[] = [];
const streamDrained = createTestDeferred<void>();
parent.subscribe((event) => {
events.push(event);
if (event.type === "provider_subagent") {
streamDrained.resolve();
}
});
try {
await parent.startTurn("Hello OpenCode");
await vi.waitFor(() => {
expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" }));
});
// Post-turn message update for the same user message ID that already completed
openCode.emitEvent({
type: "message.updated",
properties: {
info: { id: "msg_user_1", sessionID: "ses_parent_post_turn", role: "user" },
},
});
openCode.emitEvent({
type: "session.created",
properties: {
info: {
id: "ses_child_drain_marker",
parentID: "ses_parent_post_turn",
title: "Stream drain marker",
directory: "/workspace/repo",
},
},
});
await streamDrained.promise;
// Verify that no new turn_started was emitted after turn_completed
const turnStartedCount = events.filter((e) => e.type === "turn_started").length;
expect(turnStartedCount).toBe(1);
} finally {
await parent.close();
}
});
test("does not adopt late output from an interrupted Paseo turn", async () => {
const { parent, openCode } = await createParentSession("ses_parent_interrupted");
openCode.sessionPromptAsyncEvents = [];
@@ -3662,9 +3662,6 @@ class OpenCodeAgentSession implements AgentSession {
// busy. That message is the earliest unambiguous boundary for a plugin-
// initiated parent turn; session metadata and assistant echoes are not.
if (event.type === "message.updated" && event.properties.info.role === "user") {
if (this.emittedUserMessageIds.has(event.properties.info.id)) {
return false;
}
return true;
}
if (!this.externallyDriven) {

Some files were not shown because too many files have changed in this diff Show More