mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00a940e74 | ||
|
|
c81c7bb7c0 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Generated
+21
-21
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.4",
|
||||
"version": "0.2.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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)],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
|
||||
@@ -795,8 +795,7 @@ export const ar: TranslationResources = {
|
||||
deletedFile: "تم الحذف",
|
||||
commits: {
|
||||
title: "الإيداعات",
|
||||
countLabel: "{{count}} من إيداعات مساحة العمل",
|
||||
noneAhead: "لا توجد إيداعات متقدمة على {{baseRef}} بعد",
|
||||
countLabel: "{{count}} من الإيداعات الأخيرة",
|
||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||
loading: "جارٍ تحميل الإيداعات…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -806,8 +806,7 @@ export const ja: TranslationResources = {
|
||||
deletedFile: "削除済み",
|
||||
commits: {
|
||||
title: "コミット",
|
||||
countLabel: "ワークスペースのコミット数: {{count}}",
|
||||
noneAhead: "{{baseRef}} より先のコミットはまだありません",
|
||||
countLabel: "最近のコミット数: {{count}}",
|
||||
fileDiffEmpty: "表示する変更はありません",
|
||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||
loading: "コミットを読み込み中…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -817,8 +817,7 @@ export const ru: TranslationResources = {
|
||||
deletedFile: "Удалено",
|
||||
commits: {
|
||||
title: "Коммиты",
|
||||
countLabel: "{{count}} коммитов рабочего пространства",
|
||||
noneAhead: "Коммитов впереди {{baseRef}} пока нет",
|
||||
countLabel: "{{count}} последних коммитов",
|
||||
fileDiffEmpty: "Нет изменений для отображения",
|
||||
fileDiffError: "Не удалось загрузить различия файла",
|
||||
loading: "Загрузка коммитов…",
|
||||
|
||||
@@ -787,8 +787,7 @@ export const zhCN: TranslationResources = {
|
||||
deletedFile: "已删除",
|
||||
commits: {
|
||||
title: "提交",
|
||||
countLabel: "{{count}} 个工作区提交",
|
||||
noneAhead: "尚无领先于 {{baseRef}} 的提交",
|
||||
countLabel: "最近 {{count}} 个提交",
|
||||
fileDiffEmpty: "没有可显示的更改",
|
||||
fileDiffError: "加载文件差异失败",
|
||||
loading: "正在加载提交…",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.4",
|
||||
"version": "0.2.0-beta.3",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user