mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f7701155 | ||
|
|
ba68e2599b | ||
|
|
7631505a79 | ||
|
|
ed308bc64c | ||
|
|
a72d5214ce | ||
|
|
dea127bfe5 | ||
|
|
1f21974a76 | ||
|
|
ef9140df01 | ||
|
|
27f5896ebe | ||
|
|
3bd193b5ba | ||
|
|
7dd6ddef53 | ||
|
|
6197336f60 | ||
|
|
d97a5ec036 | ||
|
|
13adfee3a4 | ||
|
|
5387a16bb6 | ||
|
|
6d7848059e | ||
|
|
a324ac5d71 | ||
|
|
6f04d33b18 | ||
|
|
1526532ff4 | ||
|
|
dbff649d88 | ||
|
|
a6ad4ff430 | ||
|
|
165fc11955 | ||
|
|
426b81e5d2 | ||
|
|
2c90498cb8 | ||
|
|
c42d48a8ff | ||
|
|
54b1f524b7 | ||
|
|
85db9711b3 | ||
|
|
3c2a98b09b | ||
|
|
9e3ebd9665 | ||
|
|
a1606ea515 | ||
|
|
fa8fab75aa | ||
|
|
a3f350a7dc | ||
|
|
6a8f507d10 | ||
|
|
ea0bd47c1d | ||
|
|
f4dcb3954a | ||
|
|
7fc4dd1e86 | ||
|
|
78bb56d90d | ||
|
|
a788064e32 | ||
|
|
74f0b9da56 | ||
|
|
aa01f23ab3 | ||
|
|
ae3f1bb5df | ||
|
|
2064bac918 | ||
|
|
fa4294a0e1 | ||
|
|
c993858898 | ||
|
|
6e04153b95 | ||
|
|
a6f0d79212 | ||
|
|
4b2e0357aa | ||
|
|
acf88f4aca | ||
|
|
412c66ce4b | ||
|
|
cb906d4223 | ||
|
|
bf0d3b9786 | ||
|
|
7f37255f88 | ||
|
|
5c03e25232 | ||
|
|
b440f44111 | ||
|
|
d433e1a362 |
@@ -252,7 +252,12 @@ jobs:
|
|||||||
run: npm run test --workspace=@getpaseo/relay
|
run: npm run test --workspace=@getpaseo/relay
|
||||||
|
|
||||||
cli-tests:
|
cli-tests:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -276,3 +281,5 @@ jobs:
|
|||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||||
PASEO_DICTATION_ENABLED: "0"
|
PASEO_DICTATION_ENABLED: "0"
|
||||||
PASEO_VOICE_MODE_ENABLED: "0"
|
PASEO_VOICE_MODE_ENABLED: "0"
|
||||||
|
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||||
|
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"options": {
|
||||||
|
"typeAware": false
|
||||||
|
},
|
||||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||||
"categories": {
|
"categories": {
|
||||||
"correctness": "error",
|
"correctness": "error",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
"typescript/no-explicit-any": "error",
|
"typescript/no-explicit-any": "error",
|
||||||
"typescript/prefer-as-const": "error",
|
"typescript/prefer-as-const": "error",
|
||||||
"typescript/no-this-alias": "error",
|
"typescript/no-this-alias": "error",
|
||||||
|
"typescript/no-unnecessary-type-assertion": "error",
|
||||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||||
|
|
||||||
"import/no-unassigned-import": [
|
"import/no-unassigned-import": [
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-Pjfl4RV+2keXdYWMPonsPkwPAbVNSgczmwSeQRwNSu4=";
|
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+103
@@ -30,6 +30,7 @@
|
|||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
"oxfmt": "0.46.0",
|
"oxfmt": "0.46.0",
|
||||||
"oxlint": "1.61.0",
|
"oxlint": "1.61.0",
|
||||||
|
"oxlint-tsgolint": "^0.22.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -10376,6 +10377,90 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz",
|
||||||
@@ -31174,6 +31259,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oxlint-tsgolint": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tsgolint": "bin/tsgolint.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@oxlint-tsgolint/darwin-arm64": "0.22.1",
|
||||||
|
"@oxlint-tsgolint/darwin-x64": "0.22.1",
|
||||||
|
"@oxlint-tsgolint/linux-arm64": "0.22.1",
|
||||||
|
"@oxlint-tsgolint/linux-x64": "0.22.1",
|
||||||
|
"@oxlint-tsgolint/win32-arm64": "0.22.1",
|
||||||
|
"@oxlint-tsgolint/win32-x64": "0.22.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-cancelable": {
|
"node_modules/p-cancelable": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
"oxfmt": "0.46.0",
|
"oxfmt": "0.46.0",
|
||||||
"oxlint": "1.61.0",
|
"oxlint": "1.61.0",
|
||||||
|
"oxlint-tsgolint": "^0.22.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
|
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import {
|
||||||
|
loadRealDaemonState,
|
||||||
|
injectDesktopBridge,
|
||||||
|
openDesktopSettings,
|
||||||
|
expectUpdateBanner,
|
||||||
|
clickInstallUpdate,
|
||||||
|
expectInstallInProgress,
|
||||||
|
interceptDaemonManagementConfirmDialog,
|
||||||
|
toggleDaemonManagement,
|
||||||
|
expectDaemonManagementConfirmDialog,
|
||||||
|
expectDaemonManagementEnabled,
|
||||||
|
expectDaemonManagementDisabled,
|
||||||
|
expectDaemonStatusPid,
|
||||||
|
expectDaemonStatusLogPath,
|
||||||
|
expectDaemonStatusVersion,
|
||||||
|
} from "./helpers/desktop-updates";
|
||||||
|
|
||||||
|
function getSeededServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||||
|
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||||
|
test.describe("Desktop updates", () => {
|
||||||
|
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getSeededServerId(),
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await expectUpdateBanner(page, "1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getSeededServerId(),
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
slowInstall: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await expectUpdateBanner(page, "1.2.3");
|
||||||
|
await clickInstallUpdate(page);
|
||||||
|
await expectInstallInProgress(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Desktop daemon management", () => {
|
||||||
|
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: false,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
|
||||||
|
expectDaemonManagementConfirmDialog(dialogArgs);
|
||||||
|
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: false,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
|
||||||
|
await expectDaemonManagementDisabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
const realState = await loadRealDaemonState();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
daemonPid: realState.pid,
|
||||||
|
daemonVersion: realState.version,
|
||||||
|
daemonLogPath: realState.logPath,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonStatusVersion(page, realState.version);
|
||||||
|
await expectDaemonStatusPid(page, realState.pid);
|
||||||
|
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
const realState = await loadRealDaemonState();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
daemonPid: realState.pid,
|
||||||
|
daemonVersion: realState.version,
|
||||||
|
daemonLogPath: realState.logPath,
|
||||||
|
confirmShouldAccept: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonStatusPid(page, realState.pid);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
await expectDaemonManagementDisabled(page);
|
||||||
|
await expectDaemonStatusPid(page, null);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "enable");
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||||
|
await expectDaemonStatusPid(page, newPid);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
|
|||||||
if (!result.workspace) {
|
if (!result.workspace) {
|
||||||
throw new Error(result.error ?? "Failed to seed workspace");
|
throw new Error(result.error ?? "Failed to seed workspace");
|
||||||
}
|
}
|
||||||
workspaceId = String(result.workspace.id);
|
workspaceId = result.workspace.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
// Extend base test to provide dynamic baseURL from global-setup
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
const test = base.extend({
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
|
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||||
|
// reliably for every test that uses this `test` object.
|
||||||
|
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||||
baseURL: async ({}, provide) => {
|
baseURL: async ({}, provide) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
@@ -10,102 +15,99 @@ const test = base.extend({
|
|||||||
}
|
}
|
||||||
await provide(`http://localhost:${metroPort}`);
|
await provide(`http://localhost:${metroPort}`);
|
||||||
},
|
},
|
||||||
});
|
paseoE2ESetup: [
|
||||||
|
async ({ page }, provide, testInfo) => {
|
||||||
const consoleEntries = new WeakMap<Page, string[]>();
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
test.beforeEach(async ({ page }) => {
|
if (!daemonPort) {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
throw new Error(
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||||
if (!daemonPort) {
|
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||||
throw new Error(
|
);
|
||||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
}
|
||||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
if (daemonPort === "6767") {
|
||||||
);
|
throw new Error(
|
||||||
}
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||||
if (daemonPort === "6767") {
|
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||||
throw new Error(
|
);
|
||||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
}
|
||||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
if (!metroPort) {
|
||||||
);
|
throw new Error(
|
||||||
}
|
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||||
if (!metroPort) {
|
);
|
||||||
throw new Error(
|
|
||||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
|
||||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
|
||||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
||||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
||||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries: string[] = [];
|
|
||||||
consoleEntries.set(page, entries);
|
|
||||||
|
|
||||||
page.on("console", (message) => {
|
|
||||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
page.on("pageerror", (error) => {
|
|
||||||
entries.push(`[pageerror] ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const seedNonce = Math.random().toString(36).slice(2);
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
|
||||||
}
|
|
||||||
const testDaemon = buildSeededHost({
|
|
||||||
serverId,
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
nowIso,
|
|
||||||
});
|
|
||||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ daemon, preferences, seedNonce: nonce }) => {
|
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
|
||||||
// setting this flag before the reload.
|
|
||||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
|
||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
|
||||||
if (disableValue) {
|
|
||||||
localStorage.removeItem(disableOnceKey);
|
|
||||||
if (disableValue === nonce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||||
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||||
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
|
});
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
const entries: string[] = [];
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem("@paseo:settings");
|
page.on("console", (message) => {
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
entries.push(`[pageerror] ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const seedNonce = Math.random().toString(36).slice(2);
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||||
|
}
|
||||||
|
const testDaemon = buildSeededHost({
|
||||||
|
serverId,
|
||||||
|
endpoint: `127.0.0.1:${daemonPort}`,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ daemon, preferences, seedNonce: nonce }) => {
|
||||||
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
|
// setting this flag before the reload.
|
||||||
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
|
if (disableValue) {
|
||||||
|
localStorage.removeItem(disableOnceKey);
|
||||||
|
if (disableValue === nonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
|
|
||||||
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
localStorage.removeItem("@paseo:settings");
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
},
|
||||||
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||||
|
);
|
||||||
|
|
||||||
|
await provide();
|
||||||
|
|
||||||
|
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
||||||
|
await testInfo.attach("browser-console", {
|
||||||
|
body: entries.join("\n"),
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
{ auto: true },
|
||||||
);
|
],
|
||||||
});
|
withWorkspace: async ({ page }, provide) => {
|
||||||
|
const handle = createWithWorkspace(page);
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
await provide(handle.withWorkspace);
|
||||||
const entries = consoleEntries.get(page);
|
await handle.cleanup();
|
||||||
if (!entries || entries.length === 0) {
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testInfo.status === testInfo.expectedStatus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await testInfo.attach("browser-console", {
|
|
||||||
body: entries.join("\n"),
|
|
||||||
contentType: "text/plain",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { test, expect, type Page };
|
export { test, expect, type Page };
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
process.env.E2E_METRO_PORT = String(metroPort);
|
||||||
|
process.env.E2E_PASEO_HOME = paseoHome;
|
||||||
console.log(
|
console.log(
|
||||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||||
|
const messages = page.getByTestId("assistant-message");
|
||||||
|
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||||
|
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||||
|
}
|
||||||
+19
-294
@@ -1,217 +1,36 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
if (port === "6767") {
|
|
||||||
throw new Error(
|
|
||||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|
||||||
const port = getE2EDaemonPort();
|
|
||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!expectedServerId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsReset = await page.evaluate(
|
|
||||||
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
|
||||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
if (!raw) return true;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
|
||||||
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
|
||||||
if (entry?.serverId !== serverId) return true;
|
|
||||||
const connections = entry?.connections;
|
|
||||||
if (!Array.isArray(connections)) return true;
|
|
||||||
if (
|
|
||||||
connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" &&
|
|
||||||
typeof c?.endpoint === "string" &&
|
|
||||||
/:6767\b/.test(c.endpoint),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
return !connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ expectedEndpoint, expectedServerId },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!needsReset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const daemon = buildSeededHost({
|
|
||||||
serverId: expectedServerId,
|
|
||||||
endpoint: expectedEndpoint,
|
|
||||||
nowIso,
|
|
||||||
});
|
|
||||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
||||||
localStorage.removeItem("@paseo:settings");
|
|
||||||
},
|
|
||||||
{ daemon, preferences },
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
|
||||||
let registry: unknown;
|
|
||||||
try {
|
|
||||||
registry = JSON.parse(registryRaw);
|
|
||||||
} catch {
|
|
||||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
|
||||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { serverId: daemon.serverId, connections: daemon.connections };
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
|
||||||
if (
|
|
||||||
!Array.isArray(connections) ||
|
|
||||||
!connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Array.isArray(connections) &&
|
|
||||||
connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
|
||||||
try {
|
|
||||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
|
||||||
if (prefs?.serverId !== serverId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) throw error;
|
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|
||||||
const port = getE2EDaemonPort();
|
|
||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!expectedServerId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await page.evaluate(() => {
|
|
||||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
|
||||||
return { registryRaw, prefsRaw };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!snapshot.registryRaw) {
|
|
||||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
|
||||||
if (serverId !== expectedServerId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertDaemonConnections(connections, expectedEndpoint);
|
|
||||||
|
|
||||||
if (!snapshot.prefsRaw) {
|
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
|
||||||
}
|
|
||||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gotoAppShell = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await ensureE2EStorageSeeded(page);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gotoHome = async (page: Page) => {
|
export const gotoHome = async (page: Page) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
if (
|
const entryButton = page
|
||||||
!(await composer
|
.getByText("Add a project", { exact: true })
|
||||||
.first()
|
.or(page.getByText("Add project", { exact: true }))
|
||||||
.isVisible()
|
.or(page.getByText("New agent", { exact: true }))
|
||||||
.catch(() => false))
|
.first();
|
||||||
) {
|
|
||||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
|
||||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
|
||||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () =>
|
async () =>
|
||||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
(await composer.isVisible().catch(() => false)) ||
|
||||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
(await entryButton.isVisible().catch(() => false)),
|
||||||
(await newAgentButton.isVisible().catch(() => false)),
|
{ timeout: 10_000 },
|
||||||
{ timeout: 10000 },
|
)
|
||||||
)
|
.toBe(true);
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
if (!(await composer.isVisible().catch(() => false))) {
|
||||||
await addProjectCta.click();
|
await entryButton.click();
|
||||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
|
||||||
await addProjectSidebar.click();
|
|
||||||
} else {
|
|
||||||
await newAgentButton.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
|
||||||
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
@@ -341,46 +160,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
await ensureE2EStorageSeeded(page);
|
|
||||||
|
|
||||||
// Absolute verification that we're using the per-run e2e daemon (never :6767).
|
|
||||||
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
|
|
||||||
// realigning create-agent-preferences.serverId to the sole seeded daemon.
|
|
||||||
try {
|
|
||||||
await assertE2EUsesSeededTestDaemon(page);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (!/create-agent-preferences\.serverId/i.test(message)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fix = await page.evaluate(() => {
|
|
||||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
|
||||||
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
|
||||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1)
|
|
||||||
return { ok: false, reason: "registry shape" } as const;
|
|
||||||
const serverId = registry[0]?.serverId;
|
|
||||||
if (typeof serverId !== "string" || serverId.length === 0)
|
|
||||||
return { ok: false, reason: "missing serverId" } as const;
|
|
||||||
prefs.serverId = serverId;
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
|
||||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
|
||||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
|
||||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
|
||||||
return { ok: true } as const;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fix.ok) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await assertE2EUsesSeededTestDaemon(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
@@ -392,7 +171,7 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
if (await selectHostLabel.isVisible()) {
|
if (await selectHostLabel.isVisible()) {
|
||||||
await selectHostLabel.click();
|
await selectHostLabel.click();
|
||||||
|
|
||||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
// We enforce a single seeded daemon, so the option should be unambiguous.
|
||||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||||
const daemonIdOption = page
|
const daemonIdOption = page
|
||||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||||
@@ -657,57 +436,3 @@ export const createAgentInRepo = async (
|
|||||||
await setWorkingDirectory(page, config.directory);
|
await setWorkingDirectory(page, config.directory);
|
||||||
await createAgent(page, config.prompt);
|
await createAgent(page, config.prompt);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
|
||||||
const promptText = page.getByTestId("permission-request-question").first();
|
|
||||||
await expect(promptText).toBeVisible({ timeout });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
|
||||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
|
||||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
|
||||||
await acceptButton.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
|
||||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
|
||||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
|
||||||
await denyButton.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
|
||||||
// Wait for the stop button to disappear
|
|
||||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
|
||||||
|
|
||||||
// First, let's debug what's happening - wait a bit to see the state
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if stop button is visible
|
|
||||||
const isVisible = await stopButton.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
// If stop button is still visible after permission denial,
|
|
||||||
// it might be that the agent is waiting for something.
|
|
||||||
// Let's check if there's a tool call result or other UI indication
|
|
||||||
|
|
||||||
// Look for any indication that the agent has processed the permission denial
|
|
||||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
|
||||||
|
|
||||||
// Wait for the tool call result to appear
|
|
||||||
await expect(toolCallResult)
|
|
||||||
.toBeVisible({ timeout: 10000 })
|
|
||||||
.catch(() => {
|
|
||||||
// If no specific message, just wait for the button to disappear
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now wait for the stop button to disappear
|
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getToolCallCount(page: Page): Promise<number> {
|
|
||||||
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
|
||||||
// They appear as pressable badges in the agent stream
|
|
||||||
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
|
||||||
return toolCallBadges.count();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
function composerInput(page: Page) {
|
||||||
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerLocator(page: Page) {
|
||||||
|
return composerInput(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectComposerVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||||
|
const input = composerInput(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
await input.fill(text);
|
||||||
|
await input.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAgent(page: Page): Promise<void> {
|
||||||
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||||
|
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await stopButton.click();
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { openSettings } from "./app";
|
||||||
|
import { openSettingsHost } from "./settings";
|
||||||
|
|
||||||
|
interface DaemonApiStatus {
|
||||||
|
version: string;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PidFileContent {
|
||||||
|
pid: number;
|
||||||
|
desktopManaged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealDaemonState {
|
||||||
|
version: string;
|
||||||
|
pid: number | null;
|
||||||
|
logPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads live state from the running E2E test daemon: version from the HTTP
|
||||||
|
* status endpoint, PID from the paseo.pid lock file, log path from the
|
||||||
|
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||||
|
*/
|
||||||
|
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||||
|
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||||
|
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||||
|
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||||
|
const data = (await resp.json()) as DaemonApiStatus;
|
||||||
|
|
||||||
|
let pid: number | null = null;
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||||
|
pid = (JSON.parse(raw) as PidFileContent).pid ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
// PID file may not be present yet on a very fresh daemon start
|
||||||
|
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopBridgeConfig {
|
||||||
|
serverId: string;
|
||||||
|
updateAvailable?: boolean;
|
||||||
|
latestVersion?: string;
|
||||||
|
slowInstall?: boolean;
|
||||||
|
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||||
|
daemonPid?: number | null;
|
||||||
|
daemonVersion?: string | null;
|
||||||
|
daemonLogPath?: string;
|
||||||
|
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||||
|
manageBuiltInDaemon?: boolean;
|
||||||
|
/**
|
||||||
|
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||||
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||||
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||||
|
*/
|
||||||
|
confirmShouldAccept?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmDialogCall {
|
||||||
|
message: string;
|
||||||
|
title: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||||
|
* activates. The update-check IPC is mocked at the boundary so the real
|
||||||
|
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
||||||
|
* tracks running state and assigns a fresh PID on each start, letting tests
|
||||||
|
* observe PID changes without touching the real E2E daemon process.
|
||||||
|
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
||||||
|
* can assert dialog copy without depending on window.confirm concatenation.
|
||||||
|
*/
|
||||||
|
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||||
|
await page.addInitScript((cfg) => {
|
||||||
|
// Mutable state shared across IPC calls within this page
|
||||||
|
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||||
|
let daemonRunning = true;
|
||||||
|
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||||
|
let startCount = 0;
|
||||||
|
|
||||||
|
function buildDaemonStatus() {
|
||||||
|
return {
|
||||||
|
serverId: cfg.serverId,
|
||||||
|
status: daemonRunning ? "running" : "stopped",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: currentPid,
|
||||||
|
home: "",
|
||||||
|
version: cfg.daemonVersion ?? null,
|
||||||
|
desktopManaged: manageDaemon,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||||
|
platform: "darwin",
|
||||||
|
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
if (command === "check_app_update") {
|
||||||
|
return cfg.updateAvailable
|
||||||
|
? {
|
||||||
|
hasUpdate: true,
|
||||||
|
readyToInstall: true,
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
latestVersion: null,
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "install_app_update") {
|
||||||
|
if (cfg.slowInstall) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: cfg.latestVersion ?? "1.2.3",
|
||||||
|
message: "App update installed. Restart required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "desktop_daemon_status") {
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "desktop_daemon_logs") {
|
||||||
|
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "get_desktop_settings") {
|
||||||
|
return {
|
||||||
|
releaseChannel: "stable",
|
||||||
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "patch_desktop_settings") {
|
||||||
|
const patchDaemon =
|
||||||
|
args?.daemon && typeof args.daemon === "object"
|
||||||
|
? (args.daemon as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (typeof patchDaemon.manageBuiltInDaemon === "boolean") {
|
||||||
|
manageDaemon = patchDaemon.manageBuiltInDaemon;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
releaseChannel: "stable",
|
||||||
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "stop_desktop_daemon") {
|
||||||
|
daemonRunning = false;
|
||||||
|
currentPid = null;
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "start_desktop_daemon") {
|
||||||
|
startCount += 1;
|
||||||
|
daemonRunning = true;
|
||||||
|
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||||
|
// (after a stop) get a fresh PID so tests can observe the change.
|
||||||
|
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||||
|
(window as unknown as Record<string, unknown>).__capturedDialogCall = {
|
||||||
|
message,
|
||||||
|
title: options?.title,
|
||||||
|
};
|
||||||
|
return cfg.confirmShouldAccept ?? false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPendingOpenProject: async () => null,
|
||||||
|
events: { on: async () => () => undefined },
|
||||||
|
};
|
||||||
|
}, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
|
await openSettings(page);
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||||
|
const callout = page.getByTestId("update-callout");
|
||||||
|
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectInstallInProgress(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
||||||
|
* mock, then returns the captured call args (message + title). The mock auto-
|
||||||
|
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
||||||
|
* worrying about state changes.
|
||||||
|
*/
|
||||||
|
export async function interceptDaemonManagementConfirmDialog(
|
||||||
|
page: Page,
|
||||||
|
): Promise<ConfirmDialogCall> {
|
||||||
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !!(window as unknown as Record<string, unknown>).__capturedDialogCall,
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as unknown as Record<string, unknown>).__capturedDialogCall as ConfirmDialogCall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDaemonManagement(
|
||||||
|
page: Page,
|
||||||
|
_action: "enable" | "disable",
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
||||||
|
expect(args.title).toBe("Pause built-in daemon");
|
||||||
|
expect(args.message).toContain("stop the built-in daemon immediately");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the daemon status card shows the given PID. Pass null to assert
|
||||||
|
* the cleared state (shown as "PID —" when the daemon is stopped).
|
||||||
|
*/
|
||||||
|
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
||||||
|
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the host page identity badge shows the given version string.
|
||||||
|
* The badge is populated from the live WebSocket session's serverInfo.version.
|
||||||
|
*/
|
||||||
|
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
@@ -68,20 +68,6 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
|
||||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
|
||||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
|
||||||
await button.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
|
||||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
|
||||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
|
||||||
await button.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
@@ -90,11 +76,6 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
|||||||
|
|
||||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
|
||||||
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
|
||||||
// No-op: the launcher panel no longer exists.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -119,7 +100,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button to create a terminal tab. */
|
/** Click the new terminal button to create a terminal tab. */
|
||||||
export async function clickTerminal(page: Page): Promise<void> {
|
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
@@ -193,6 +174,19 @@ export async function sampleTabsDuringTransition(
|
|||||||
return snapshots;
|
return snapshots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function terminalSurfaceLocator(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||||
|
const tabTestId = `workspace-tab-agent_${agentId}`;
|
||||||
|
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Create a temp git repo and return its path with a cleanup function. */
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function allowPermission(page: Page): Promise<void> {
|
||||||
|
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||||
|
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||||
|
await acceptButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function denyPermission(page: Page): Promise<void> {
|
||||||
|
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||||
|
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||||
|
await denyButton.click();
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { requireServerId } from "./sidebar";
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_LABELS = {
|
||||||
|
general: "General",
|
||||||
|
shortcuts: "Shortcuts",
|
||||||
|
integrations: "Integrations",
|
||||||
|
permissions: "Permissions",
|
||||||
|
diagnostics: "Diagnostics",
|
||||||
|
about: "About",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||||
|
|
||||||
|
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
|
||||||
|
if (section === "projects") {
|
||||||
|
await page.getByTestId("settings-projects").click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||||
|
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("settings-add-host").click();
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectHostConnectionType(
|
||||||
|
page: Page,
|
||||||
|
type: "direct" | "relay",
|
||||||
|
): Promise<void> {
|
||||||
|
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||||
|
await page.getByRole("button", { name: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCompactSettings(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||||
|
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||||
|
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||||
|
await expect(settingsButton).toBeVisible();
|
||||||
|
await settingsButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarSections(
|
||||||
|
page: Page,
|
||||||
|
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||||
|
): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
for (const section of sections) {
|
||||||
|
await expect(
|
||||||
|
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goBackInSettings(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("settings-back-to-workspace").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||||
|
const serverId = requireServerId();
|
||||||
|
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||||
|
await expectHostSettingsUrl(page, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||||
|
const serverId = requireServerId();
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await expectHostSettingsUrl(page, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
|
||||||
|
await page.getByTestId("direct-host-uri-input").fill(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostFormValues(
|
||||||
|
page: Page,
|
||||||
|
fields: { host: string; port: string; password: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
|
||||||
|
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
|
||||||
|
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAboutContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("host-page-label-edit-button").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||||
|
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||||
|
const card = page.getByTestId("host-page-connections-card");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||||
|
const card = page.getByTestId("host-page-inject-mcp-card");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
||||||
|
"data-testid",
|
||||||
|
`settings-host-entry-${serverId}`,
|
||||||
|
);
|
||||||
|
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||||
|
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||||
|
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export function requireServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -263,6 +263,34 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectTerminalSurfaceVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
|
||||||
|
timeout: options?.timeout ?? 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function focusTerminalSurface(page: Page): Promise<void> {
|
||||||
|
await expectTerminalSurfaceVisible(page);
|
||||||
|
await page.locator('[data-testid="terminal-surface"]').first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function typeInTerminal(page: Page, text: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-surface"]')
|
||||||
|
.first()
|
||||||
|
.pressSequentially(text, { delay: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalAttached(page: Page): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export function computePercentile(samples: number[], p: number): number {
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
|||||||
@@ -204,8 +204,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
|||||||
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||||
const originalWrite = next.write.bind(next);
|
const originalWrite = next.write.bind(next);
|
||||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
const text =
|
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
|
||||||
probe.writeCount += 1;
|
probe.writeCount += 1;
|
||||||
const preview = text
|
const preview = text
|
||||||
.replaceAll("\u001b", "\\x1b")
|
.replaceAll("\u001b", "\\x1b")
|
||||||
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
|||||||
const count = Math.min(from.length, to.length);
|
const count = Math.min(from.length, to.length);
|
||||||
const values: number[] = [];
|
const values: number[] = [];
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
values.push(to[index]!.at - from[index]!.at);
|
values.push(to[index].at - from[index].at);
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
|||||||
const keydownToInputFrame = this.keydowns
|
const keydownToInputFrame = this.keydowns
|
||||||
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||||
.filter((at): at is number => at !== null)
|
.filter((at): at is number => at !== null)
|
||||||
.map((at, index) => at - this.keydowns[index]!.at);
|
.map((at, index) => at - this.keydowns[index].at);
|
||||||
const inputFrameToOutputFrame = this.inputFrames
|
const inputFrameToOutputFrame = this.inputFrames
|
||||||
.map((input) => {
|
.map((input) => {
|
||||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||||
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
|||||||
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||||
const originalWrite = next.write.bind(next);
|
const originalWrite = next.write.bind(next);
|
||||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
const text =
|
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
|
||||||
const event: XtermWriteEvent = {
|
const event: XtermWriteEvent = {
|
||||||
at: performance.now(),
|
at: performance.now(),
|
||||||
committedAt: null,
|
committedAt: null,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { waitForTabBar } from "./launcher";
|
||||||
|
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
openHomeWithProject,
|
||||||
|
type WorkspaceSetupDaemonClient,
|
||||||
|
} from "./workspace-setup";
|
||||||
|
|
||||||
|
export interface CreatedWorkspace {
|
||||||
|
workspaceId: string;
|
||||||
|
repoPath: string;
|
||||||
|
navigateTo(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithWorkspaceOptions {
|
||||||
|
worktree?: boolean;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
|
||||||
|
|
||||||
|
interface WorktreeRecord {
|
||||||
|
repoPath: string;
|
||||||
|
worktreePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithWorkspaceHandle {
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||||
|
let client: WorkspaceSetupDaemonClient | null = null;
|
||||||
|
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||||
|
const worktrees: WorktreeRecord[] = [];
|
||||||
|
|
||||||
|
const withWorkspace: WithWorkspace = async (options) => {
|
||||||
|
if (!client) {
|
||||||
|
client = await connectWorkspaceSetupClient();
|
||||||
|
}
|
||||||
|
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
|
||||||
|
const repo = await createTempGitRepo(prefix);
|
||||||
|
repos.push(repo);
|
||||||
|
|
||||||
|
let workspacePath = repo.path;
|
||||||
|
if (options?.worktree) {
|
||||||
|
const tempRoot = await realpath("/tmp");
|
||||||
|
workspacePath = path.join(
|
||||||
|
tempRoot,
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `paseo-wt-${Date.now()}`;
|
||||||
|
execSync(
|
||||||
|
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
|
{ cwd: repo.path, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||||
|
// Register the parent project so the sidebar lists it before we navigate.
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = await client.openProject(workspacePath);
|
||||||
|
if (!opened.workspace) {
|
||||||
|
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = opened.workspace.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
repoPath: workspacePath,
|
||||||
|
navigateTo: async () => {
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await selectWorkspaceInSidebar(page, workspaceId);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
withWorkspace,
|
||||||
|
cleanup: async () => {
|
||||||
|
for (const { repoPath, worktreePath } of worktrees) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so the original test failure is preserved.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const repo of repos) {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
if (client) {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { clickNewChat, clickTerminal } from "./launcher";
|
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||||
|
|
||||||
function terminalSurface(page: Page) {
|
function terminalSurface(page: Page) {
|
||||||
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||||
import { gotoAppShell } from "./app";
|
import { gotoAppShell } from "./app";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
interface WorkspaceSetupDaemonClient {
|
interface WorkspaceSetupDaemonClient {
|
||||||
@@ -265,7 +266,7 @@ export async function createWorkspaceThroughDaemon(
|
|||||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: String(result.workspace.id),
|
id: result.workspace.id,
|
||||||
name: result.workspace.name,
|
name: result.workspace.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -291,7 +292,7 @@ export async function findWorktreeWorkspaceForProject(
|
|||||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: String(workspace.id),
|
id: workspace.id,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
projectRootPath: workspace.projectRootPath,
|
projectRootPath: workspace.projectRootPath,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
@@ -308,13 +309,37 @@ export async function fetchWorkspaceById(
|
|||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
}> {
|
}> {
|
||||||
const payload = await client.fetchWorkspaces();
|
const payload = await client.fetchWorkspaces();
|
||||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
const workspace = payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||||
}
|
}
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("workspace-scripts-button").click();
|
||||||
|
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
|
||||||
|
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceSetupProgress(
|
export async function waitForWorkspaceSetupProgress(
|
||||||
client: WorkspaceSetupDaemonClient,
|
client: WorkspaceSetupDaemonClient,
|
||||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoTerminalTabs(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickFirstTerminalTab(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
|
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
await tab.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {},
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
|
|||||||
@@ -114,6 +114,49 @@ export async function expectWorkspaceHeader(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectReconnectingToastVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||||
|
timeout: options?.timeout ?? 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectReconnectingToastGone(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||||
|
timeout: options?.timeout ?? 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostConnectingOrOffline(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||||
|
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectMenuButtonVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
|
||||||
|
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -9,10 +9,35 @@ interface TempRepo {
|
|||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function configureRemote(input: {
|
||||||
|
repoPath: string;
|
||||||
|
withRemote: boolean;
|
||||||
|
originUrl: string | undefined;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { repoPath, withRemote, originUrl } = input;
|
||||||
|
if (withRemote) {
|
||||||
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
|
const remoteDir = path.join(repoPath, "remote.git");
|
||||||
|
await mkdir(remoteDir, { recursive: true });
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (originUrl) {
|
||||||
|
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
|
||||||
|
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
options?: {
|
options?: {
|
||||||
withRemote?: boolean;
|
withRemote?: boolean;
|
||||||
|
originUrl?: string;
|
||||||
paseoConfig?: Record<string, unknown>;
|
paseoConfig?: Record<string, unknown>;
|
||||||
files?: Array<{ path: string; content: string }>;
|
files?: Array<{ path: string; content: string }>;
|
||||||
branches?: string[];
|
branches?: string[];
|
||||||
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
|
|||||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (withRemote) {
|
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
|
||||||
const remoteDir = path.join(repoPath, "remote.git");
|
|
||||||
await mkdir(remoteDir, { recursive: true });
|
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
|
||||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
|
||||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
assertTerminalTileVisible,
|
assertTerminalTileVisible,
|
||||||
assertSingleNewTabButton,
|
assertSingleNewTabButton,
|
||||||
clickNewTabButton,
|
|
||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickTerminal,
|
clickNewTerminal,
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
sampleTabsDuringTransition,
|
sampleTabsDuringTransition,
|
||||||
|
terminalSurfaceLocator,
|
||||||
} from "./helpers/launcher";
|
} from "./helpers/launcher";
|
||||||
|
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||||
|
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectTerminalClient,
|
connectTerminalClient,
|
||||||
setupDeterministicPrompt,
|
setupDeterministicPrompt,
|
||||||
@@ -32,7 +34,7 @@ test.beforeAll(async () => {
|
|||||||
seedClient = await connectTerminalClient();
|
seedClient = await connectTerminalClient();
|
||||||
const result = await seedClient.openProject(tempRepo.path);
|
const result = await seedClient.openProject(tempRepo.path);
|
||||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||||
workspaceId = String(result.workspace.id);
|
workspaceId = result.workspace.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
@@ -50,9 +52,7 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
// Should show the composer directly (no launcher panel)
|
await expectComposerVisible(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||||
@@ -77,11 +77,9 @@ test.describe("Tab creation", () => {
|
|||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewChat(page);
|
||||||
|
|
||||||
// Draft composer should appear (the agent message input)
|
await expectComposerVisible(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const tabsAfter = await getTabTestIds(page);
|
const tabsAfter = await getTabTestIds(page);
|
||||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
@@ -92,11 +90,9 @@ test.describe("Tab creation", () => {
|
|||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
// Terminal surface should appear
|
await expectTerminalSurfaceVisible(page);
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
const tabsAfter = await getTabTestIds(page);
|
const tabsAfter = await getTabTestIds(page);
|
||||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
@@ -141,19 +137,18 @@ test.describe("Terminal title propagation", () => {
|
|||||||
try {
|
try {
|
||||||
// Navigate to workspace and open a terminal
|
// Navigate to workspace and open a terminal
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
await expectTerminalSurfaceVisible(page);
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
await terminalSurfaceLocator(page).click();
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
// Send OSC 0 (set window title) escape sequence
|
// Send OSC 0 (set window title) escape sequence
|
||||||
const testTitle = `E2E-Title-${Date.now()}`;
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||||
.first()
|
delay: 0,
|
||||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
});
|
||||||
|
|
||||||
// Wait for the tab to reflect the new title
|
// Wait for the tab to reflect the new title
|
||||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
@@ -171,24 +166,24 @@ test.describe("Terminal title propagation", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
await expectTerminalSurfaceVisible(page);
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
await terminalSurfaceLocator(page).click();
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
// Fire many rapid title changes — only the last should stick
|
// Fire many rapid title changes — only the last should stick
|
||||||
const finalTitle = `Final-${Date.now()}`;
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||||
.first()
|
delay: 0,
|
||||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
});
|
||||||
}
|
}
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(
|
||||||
.first()
|
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
// The tab should eventually settle on the final title
|
// The tab should eventually settle on the final title
|
||||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
@@ -228,11 +223,10 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
test.setTimeout(30_000);
|
test.setTimeout(30_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
const elapsed = await measureTileTransition(
|
const elapsed = await measureTileTransition(
|
||||||
page,
|
page,
|
||||||
() => clickTerminal(page),
|
() => clickNewTerminal(page),
|
||||||
terminal.first(),
|
terminalSurfaceLocator(page),
|
||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,9 +239,12 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
() => clickNewChat(page),
|
||||||
|
composerLocator(page),
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
// Draft creation is fully in-memory — should be fast
|
// Draft creation is fully in-memory — should be fast
|
||||||
// We use a generous budget here because CI can be slow, but the key assertion
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
|
|||||||
|
|
||||||
interface ProjectsSettingsFixtures {
|
interface ProjectsSettingsFixtures {
|
||||||
editableProject: ProjectsSettingsProject;
|
editableProject: ProjectsSettingsProject;
|
||||||
|
gitlabRemoteProject: ProjectsSettingsProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialPaseoConfig = {
|
const initialPaseoConfig = {
|
||||||
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
|||||||
path: repo.path,
|
path: repo.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
},
|
||||||
|
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||||
|
const client = await connectNewWorkspaceDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
||||||
|
paseoConfig: initialPaseoConfig,
|
||||||
|
originUrl: "https://gitlab.com/acme/app.git",
|
||||||
|
});
|
||||||
|
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||||
|
|
||||||
|
await provide({
|
||||||
|
name: openedProject.projectDisplayName,
|
||||||
|
path: repo.path,
|
||||||
|
});
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
},
|
},
|
||||||
@@ -119,4 +136,16 @@ test.describe("Projects settings", () => {
|
|||||||
await saveProjectConfig(page);
|
await saveProjectConfig(page);
|
||||||
await expectProjectConfigSaved(editableProject);
|
await expectProjectConfigSaved(editableProject);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||||
|
page,
|
||||||
|
gitlabRemoteProject,
|
||||||
|
}) => {
|
||||||
|
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||||
|
await openProjects(page);
|
||||||
|
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||||
|
await editWorktreeSetup(page, updatedSetup);
|
||||||
|
await saveProjectConfig(page);
|
||||||
|
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||||
|
import {
|
||||||
|
expectSettingsHeader,
|
||||||
|
openSettingsHost,
|
||||||
|
expectHostLabelDisplayed,
|
||||||
|
clickEditHostLabel,
|
||||||
|
expectHostLabelEditMode,
|
||||||
|
expectHostConnectionsCard,
|
||||||
|
expectHostInjectMcpCard,
|
||||||
|
expectHostActionCards,
|
||||||
|
expectHostNoLocalOnlyRows,
|
||||||
|
expectRetiredSidebarSectionsAbsent,
|
||||||
|
expectHostPageVisible,
|
||||||
|
expectLocalHostEntryFirst,
|
||||||
|
} from "./helpers/settings";
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
function getSeededServerId(): string {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -18,17 +32,6 @@ function getSeededDaemonPort(): string {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openHostPage(page: Page, serverId: string) {
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectHostLabelHeader(page: Page) {
|
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
|
||||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||||
page,
|
page,
|
||||||
@@ -38,28 +41,13 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||||
await expectHostLabelHeader(page);
|
await expectHostLabelDisplayed(page);
|
||||||
|
await expectHostConnectionsCard(page, port);
|
||||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
await expectHostInjectMcpCard(page);
|
||||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
await expectHostActionCards(page);
|
||||||
await expect(connectionsCard).toBeVisible();
|
|
||||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
|
||||||
await expect(injectMcpCard).toBeVisible();
|
|
||||||
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||||
@@ -67,15 +55,11 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
await expectHostLabelDisplayed(page);
|
||||||
|
await clickEditHostLabel(page);
|
||||||
await page.getByTestId("host-page-label-edit-button").click();
|
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
|
||||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||||
@@ -85,31 +69,17 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||||
// Pair-device now lives behind a row that only the local host sees
|
await expectHostNoLocalOnlyRows(page);
|
||||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
|
||||||
// not appear. The daemon-lifecycle card is still local-host only.
|
|
||||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
await expectRetiredSidebarSectionsAbsent(page);
|
||||||
await expect(sidebar).toBeVisible();
|
|
||||||
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
|
||||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||||
@@ -120,9 +90,10 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
await expectHostPageVisible(page, serverId);
|
||||||
await expectHostLabelHeader(page);
|
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
await expectHostLabelDisplayed(page);
|
||||||
|
await expectHostActionCards(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||||
@@ -164,18 +135,6 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await expectLocalHostEntryFirst(page, serverId);
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
|
||||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
|
||||||
await expect(hostEntries.first()).toHaveAttribute(
|
|
||||||
"data-testid",
|
|
||||||
`settings-host-entry-${serverId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
|
||||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
|
||||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,62 +1,60 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
import {
|
||||||
function escapeRegex(value: string): string {
|
openSettingsSection,
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
expectSettingsHeader,
|
||||||
}
|
openAddHostFlow,
|
||||||
|
selectHostConnectionType,
|
||||||
function getServerId(): string {
|
toggleHostAdvanced,
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
openCompactSettings,
|
||||||
if (!serverId) {
|
expectCompactSettingsList,
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
expectSettingsSidebarVisible,
|
||||||
}
|
expectSettingsSidebarHidden,
|
||||||
return serverId;
|
expectSettingsSidebarSections,
|
||||||
}
|
goBackInSettings,
|
||||||
|
expectSettingsBackButton,
|
||||||
async function clickSidebarSection(page: Page, label: string) {
|
clickSettingsBackToWorkspace,
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
verifyLegacyHostSettingsRedirect,
|
||||||
await expect(sidebar).toBeVisible();
|
openCompactSettingsHost,
|
||||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
expectAddHostMethodOptions,
|
||||||
}
|
fillDirectHostUri,
|
||||||
|
expectDirectHostFormValues,
|
||||||
|
expectDirectHostSslEnabled,
|
||||||
|
expectDirectHostUriValue,
|
||||||
|
expectDirectHostUriHidden,
|
||||||
|
expectDiagnosticsContent,
|
||||||
|
expectAboutContent,
|
||||||
|
expectGeneralContent,
|
||||||
|
} from "./helpers/settings";
|
||||||
|
|
||||||
test.describe("Settings sidebar navigation", () => {
|
test.describe("Settings sidebar navigation", () => {
|
||||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await clickSidebarSection(page, "Diagnostics");
|
await openSettingsSection(page, "diagnostics");
|
||||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
await expectSettingsHeader(page, "Diagnostics");
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
await expectDiagnosticsContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
|
||||||
|
|
||||||
await clickSidebarSection(page, "About");
|
await openSettingsSection(page, "about");
|
||||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
await expectSettingsHeader(page, "About");
|
||||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
await expectAboutContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
|
||||||
|
|
||||||
await clickSidebarSection(page, "General");
|
await openSettingsSection(page, "general");
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expectSettingsHeader(page, "General");
|
||||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
await expectGeneralContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
await verifyLegacyHostSettingsRedirect(page);
|
||||||
await expect(page).toHaveURL(
|
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openAddHostFlow(page);
|
||||||
await page.getByTestId("settings-add-host").click();
|
await expectAddHostMethodOptions(page);
|
||||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||||
@@ -64,38 +62,34 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openAddHostFlow(page);
|
||||||
|
await selectHostConnectionType(page, "direct");
|
||||||
|
|
||||||
await page.getByTestId("settings-add-host").click();
|
await toggleHostAdvanced(page);
|
||||||
await page.getByRole("button", { name: "Direct connection" }).click();
|
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||||
|
await toggleHostAdvanced(page);
|
||||||
|
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
await expectDirectHostFormValues(page, {
|
||||||
await page
|
host: "example.paseo.test",
|
||||||
.getByTestId("direct-host-uri-input")
|
port: "7443",
|
||||||
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
password: "shared-secret",
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
});
|
||||||
|
await expectDirectHostSslEnabled(page);
|
||||||
|
await expectDirectHostUriHidden(page);
|
||||||
|
|
||||||
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
|
await toggleHostAdvanced(page);
|
||||||
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
|
await expectDirectHostUriValue(
|
||||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
page,
|
||||||
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
|
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
|
|
||||||
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
||||||
);
|
);
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
await toggleHostAdvanced(page);
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
await expectDirectHostUriHidden(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await clickSettingsBackToWorkspace(page);
|
||||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
|
||||||
await expect(backRow).toBeVisible();
|
|
||||||
|
|
||||||
await backRow.click();
|
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,113 +97,57 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
test.describe("Settings — compact master-detail", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
async function openCompactSettingsRoot(page: Page) {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
|
||||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
|
||||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
|
||||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
|
||||||
|
|
||||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
|
||||||
// the Settings footer icon. This preserves the in-app router state instead of
|
|
||||||
// triggering a full reload through Stack.Protected.
|
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
|
||||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(sidebarSettingsButton).toBeVisible();
|
|
||||||
await sidebarSettingsButton.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectCompactSettingsRootList(page: Page) {
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
|
||||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
// Sidebar rows are present.
|
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||||
await expect(
|
await expectCompactSettingsList(page);
|
||||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Section detail content is NOT rendered at the root.
|
await expectSettingsBackButton(page);
|
||||||
await expectCompactSettingsRootList(page);
|
await goBackInSettings(page);
|
||||||
|
|
||||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
|
||||||
await expect(rootBackButton).toBeVisible();
|
|
||||||
await rootBackButton.click();
|
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page
|
await openSettingsSection(page, "diagnostics");
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
await expectDiagnosticsContent(page);
|
||||||
// Sidebar is no longer visible — we are on a detail screen.
|
await expectSettingsSidebarHidden(page);
|
||||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
await expectSettingsBackButton(page);
|
||||||
// only visible instances.)
|
|
||||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page
|
await openSettingsSection(page, "about");
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "About", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
await goBackInSettings(page);
|
||||||
await expectCompactSettingsRootList(page);
|
await expectCompactSettingsList(page);
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
await expectSettingsBackButton(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
await gotoAppShell(page);
|
||||||
await openCompactSettingsRoot(page);
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
await openCompactSettingsHost(page);
|
||||||
await expect(page).toHaveURL(
|
await expectSettingsBackButton(page);
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
await expectSettingsSidebarHidden(page);
|
||||||
);
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
await gotoAppShell(page);
|
||||||
await openCompactSettingsRoot(page);
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
await openCompactSettingsHost(page);
|
||||||
await expect(page).toHaveURL(
|
await goBackInSettings(page);
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expectSettingsSidebarVisible(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
function getServerId(): string {
|
function getServerId(): string {
|
||||||
@@ -17,12 +17,15 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pressSettingsToggleShortcut(page: Page) {
|
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
await page.keyboard.press(`${modifier}+Comma`);
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
async function expectSendBehavior(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
expected: "interrupt" | "queue",
|
||||||
|
) {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||||
@@ -35,12 +38,11 @@ async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openAgentRouteAndExpectFocused(input: {
|
async function openAgentRouteAndExpectFocused(input: {
|
||||||
page: Page;
|
page: import("@playwright/test").Page;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
}) {
|
}) {
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
|
||||||
await input.page.goto(
|
await input.page.goto(
|
||||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||||
);
|
);
|
||||||
@@ -49,10 +51,7 @@ async function openAgentRouteAndExpectFocused(input: {
|
|||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
await waitForTabBar(input.page);
|
await waitForTabBar(input.page);
|
||||||
await expect(
|
await expectAgentTabActive(input.page, input.agentId);
|
||||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Settings toggle tab regression", () => {
|
test.describe("Settings toggle tab regression", () => {
|
||||||
@@ -79,13 +78,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
|
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
@@ -98,19 +91,11 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
@@ -152,14 +137,10 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
agentId: secondAgent.id,
|
agentId: secondAgent.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async function openProjectViaDaemon(
|
|||||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: String(result.workspace.id),
|
id: result.workspace.id,
|
||||||
name: result.workspace.name,
|
name: result.workspace.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ async function measureDaemonBurstEcho(
|
|||||||
timeoutMs: STRESS_TIMEOUT_MS,
|
timeoutMs: STRESS_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index] - sentAt);
|
||||||
return {
|
return {
|
||||||
inputTextLength: inputText.length,
|
inputTextLength: inputText.length,
|
||||||
inputFrameCount: sendTimes.length,
|
inputFrameCount: sendTimes.length,
|
||||||
|
|||||||
@@ -1,129 +1,45 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { test } from "./fixtures";
|
||||||
import { realpathSync } from "node:fs";
|
import { clickNewTerminal } from "./helpers/launcher";
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
expectTerminalSurfaceVisible,
|
||||||
openHomeWithProject,
|
focusTerminalSurface,
|
||||||
seedProjectForWorkspaceSetup,
|
typeInTerminal,
|
||||||
} from "./helpers/workspace-setup";
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
function getServerId(): string {
|
} from "./helpers/terminal-perf";
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace cwd correctness", () => {
|
test.describe("Workspace cwd correctness", () => {
|
||||||
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
test("main checkout workspace opens terminals in the project root", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
await workspace.navigateTo();
|
||||||
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
try {
|
await expectTerminalSurfaceVisible(page);
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await focusTerminalSurface(page);
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
await typeInTerminal(page, "pwd\n");
|
||||||
if (!workspaceResult.workspace) {
|
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await clickTerminal(page);
|
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
|
||||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
|
||||||
|
|
||||||
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
await workspace.navigateTo();
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
await clickNewTerminal(page);
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `workspace-cwd-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
await expectTerminalSurfaceVisible(page);
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await focusTerminalSurface(page);
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
execSync(
|
await typeInTerminal(page, "pwd\n");
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
|
||||||
// with direct URL navigation to the 2nd+ workspace.
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
|
|
||||||
await clickTerminal(page);
|
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
|
||||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
|
||||||
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { test } from "./fixtures";
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { waitForTabBar } from "./helpers/launcher";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
createAgentChatFromLauncher,
|
createAgentChatFromLauncher,
|
||||||
createStandaloneTerminalFromLauncher,
|
createStandaloneTerminalFromLauncher,
|
||||||
expectTerminalCwd,
|
expectTerminalCwd,
|
||||||
} from "./helpers/workspace-lifecycle";
|
} from "./helpers/workspace-lifecycle";
|
||||||
import {
|
|
||||||
connectWorkspaceSetupClient,
|
|
||||||
openHomeWithProject,
|
|
||||||
seedProjectForWorkspaceSetup,
|
|
||||||
} from "./helpers/workspace-setup";
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace lifecycle", () => {
|
test.describe("Workspace lifecycle", () => {
|
||||||
// The first test after a spec-file switch can intermittently fail because
|
// The first test after a spec-file switch can intermittently fail because
|
||||||
@@ -42,154 +12,36 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
test.describe("Main checkout", () => {
|
||||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
await createAgentChatFromLauncher(page);
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createAgentChatFromLauncher(page);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
|
||||||
await expectTerminalCwd(page, repo.path);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
test.describe("Worktree workspace", () => {
|
||||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
await createAgentChatFromLauncher(page);
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
|
|
||||||
execSync(
|
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createAgentChatFromLauncher(page);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
|
|
||||||
execSync(
|
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
|
||||||
await expectTerminalCwd(page, worktreePath);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import type { WebSocketRoute } from "@playwright/test";
|
import type { WebSocketRoute } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
@@ -15,17 +15,26 @@ import {
|
|||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
getVisibleWorkspaceAgentTabIds,
|
getVisibleWorkspaceAgentTabIds,
|
||||||
expectOnlyWorkspaceAgentTabsVisible,
|
expectOnlyWorkspaceAgentTabsVisible,
|
||||||
waitForWorkspaceTabsVisible,
|
waitForWorkspaceTabsVisible,
|
||||||
|
expectWorkspaceTabsAbsent,
|
||||||
} from "./helpers/workspace-tabs";
|
} from "./helpers/workspace-tabs";
|
||||||
import {
|
import {
|
||||||
expectSidebarWorkspaceSelected,
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
|
expectWorkspaceHeaderAbsent,
|
||||||
|
expectMenuButtonVisible,
|
||||||
|
expectHostConnectingOrOffline,
|
||||||
|
expectReconnectingToastVisible,
|
||||||
|
expectReconnectingToastGone,
|
||||||
switchWorkspaceViaSidebar,
|
switchWorkspaceViaSidebar,
|
||||||
waitForSidebarHydration,
|
waitForSidebarHydration,
|
||||||
|
workspaceDeckEntryLocator,
|
||||||
|
expectWorkspaceDeckEntryCount,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
@@ -62,6 +71,10 @@ async function expectNoLoadingWorkspacePane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectNoLoadingPane(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
@@ -159,35 +172,31 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
await expectReconnectingToastVisible(page);
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expectComposerVisible(page);
|
||||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
await expectNoLoadingPane(page);
|
||||||
|
|
||||||
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
||||||
label: "host reconnect",
|
label: "host reconnect",
|
||||||
});
|
});
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
await expectReconnectingToastGone(page);
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await monitorReconnect;
|
await monitorReconnect;
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expectComposerVisible(page);
|
||||||
} finally {
|
} finally {
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
@@ -222,16 +231,11 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expectHostConnectingOrOffline(page);
|
||||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
await expectMenuButtonVisible(page);
|
||||||
).toBeVisible({ timeout: 30_000 });
|
await expectWorkspaceHeaderAbsent(page);
|
||||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
await expectWorkspaceTabsAbsent(page);
|
||||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
await openSettings(page);
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
|
||||||
|
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,6 +274,13 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
|
||||||
|
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
|
||||||
|
const secondDeckEntry = workspaceDeckEntryLocator(
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
secondWorkspace.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -300,13 +311,6 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${firstAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const firstDeckEntry = page.getByTestId(
|
|
||||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
|
||||||
);
|
|
||||||
const secondDeckEntry = page.getByTestId(
|
|
||||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
|
||||||
);
|
|
||||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
@@ -342,7 +346,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(firstDeckEntry).toBeAttached();
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
await expect(firstDeckEntry).toBeHidden();
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ agentId, serverId: targetServerId }) => {
|
({ agentId, serverId: targetServerId }) => {
|
||||||
@@ -371,7 +375,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||||
await expect(firstDeckEntry).toBeAttached();
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
await expect(firstDeckEntry).toBeHidden();
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
@@ -385,7 +389,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(secondDeckEntry).toBeAttached();
|
await expect(secondDeckEntry).toBeAttached();
|
||||||
await expect(secondDeckEntry).toBeHidden();
|
await expect(secondDeckEntry).toBeHidden();
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
import { clickNewTerminal } from "./helpers/launcher";
|
||||||
|
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createWorkspaceThroughDaemon,
|
createWorkspaceThroughDaemon,
|
||||||
findWorktreeWorkspaceForProject,
|
findWorktreeWorkspaceForProject,
|
||||||
|
navigateToWorkspaceViaSidebar,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace setup runtime authority", () => {
|
test.describe("Workspace setup runtime authority", () => {
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
@@ -42,13 +26,12 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const workspaceId = String(workspace.id);
|
const workspaceId = workspace.id;
|
||||||
|
|
||||||
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
||||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||||
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||||
|
|
||||||
// Navigate to the workspace via sidebar
|
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
@@ -78,7 +61,7 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
throw new Error(result.error ?? "Failed to create workspace");
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
}
|
}
|
||||||
const workspaceDir = result.workspace.workspaceDirectory;
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
const workspaceId = String(result.workspace.id);
|
const workspaceId = result.workspace.id;
|
||||||
|
|
||||||
// Navigate to the worktree workspace via sidebar click (direct URL
|
// Navigate to the worktree workspace via sidebar click (direct URL
|
||||||
// navigation for freshly created worktree workspaces can race with
|
// navigation for freshly created worktree workspaces can race with
|
||||||
@@ -86,10 +69,8 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
await expectTerminalSurfaceVisible(page);
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
// Verify terminal is listed under the worktree directory, not the original repo
|
// Verify terminal is listed under the worktree directory, not the original repo
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import {
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
expectNoTerminalTabs,
|
||||||
|
clickFirstTerminalTab,
|
||||||
|
expectFirstTerminalTabContains,
|
||||||
|
} from "./helpers/workspace-tabs";
|
||||||
|
import { clickNewChat } from "./helpers/launcher";
|
||||||
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
|
import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer";
|
||||||
|
import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createWorkspaceThroughDaemon,
|
createWorkspaceThroughDaemon,
|
||||||
expectSetupPanel,
|
expectSetupPanel,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
|
navigateToWorkspaceViaSidebar,
|
||||||
|
openWorkspaceScriptsMenu,
|
||||||
|
startWorkspaceScriptFromMenu,
|
||||||
|
closeWorkspaceScriptsMenu,
|
||||||
seedProjectForWorkspaceSetup,
|
seedProjectForWorkspaceSetup,
|
||||||
waitForWorkspaceSetupProgress,
|
waitForWorkspaceSetupProgress,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceScriptStarter {
|
interface WorkspaceScriptStarter {
|
||||||
startWorkspaceScript(
|
startWorkspaceScript(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -30,18 +35,6 @@ interface WorkspaceScriptStarter {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace setup streaming", () => {
|
test.describe("Workspace setup streaming", () => {
|
||||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
@@ -100,31 +93,15 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
});
|
});
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
// Navigate to workspace and verify it's usable
|
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
await clickNewChat(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
timeout: 30_000,
|
await openFileExplorer(page);
|
||||||
});
|
await expectExplorerEntryVisible(page, "README.md");
|
||||||
|
await expectExplorerEntryVisible(page, "src");
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
|
||||||
await explorerToggle.click();
|
|
||||||
}
|
|
||||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await page.getByTestId("explorer-tab-files").click();
|
|
||||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
@@ -273,33 +250,14 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectNoTerminalTabs(page);
|
||||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
await openWorkspaceScriptsMenu(page);
|
||||||
await page.getByTestId("workspace-scripts-button").click();
|
await startWorkspaceScriptFromMenu(page, "web");
|
||||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
await closeWorkspaceScriptsMenu(page);
|
||||||
await page.getByTestId("workspace-scripts-start-web").click();
|
await clickFirstTerminalTab(page);
|
||||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
|
||||||
|
await waitForTerminalAttached(page);
|
||||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
await expectFirstTerminalTabContains(page, "web");
|
||||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await terminalTab.click();
|
|
||||||
|
|
||||||
// Verify the terminal surface rendered
|
|
||||||
const terminalSurface = page.getByTestId("terminal-surface").first();
|
|
||||||
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
// Wait for terminal to fully attach (loading overlay gone)
|
|
||||||
await page
|
|
||||||
.locator('[data-testid="terminal-attach-loading"]')
|
|
||||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
|
||||||
.catch(() => {
|
|
||||||
// overlay may never appear if attachment is instant
|
|
||||||
});
|
|
||||||
|
|
||||||
await terminalSurface.click();
|
|
||||||
|
|
||||||
await expect(terminalTab).toContainText("web");
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
@@ -337,7 +295,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
throw new Error(result.error ?? "Failed to create workspace");
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
}
|
}
|
||||||
const workspaceDir = result.workspace.workspaceDirectory;
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
const workspaceId = String(result.workspace.id);
|
const workspaceId = result.workspace.id;
|
||||||
|
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,34 @@ platform :ios do
|
|||||||
app_identifier: APP_IDENTIFIER,
|
app_identifier: APP_IDENTIFIER,
|
||||||
)
|
)
|
||||||
|
|
||||||
wait_for_build_processing_to_be_complete(
|
require "spaceship"
|
||||||
api_key: api_key,
|
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||||
app_identifier: APP_IDENTIFIER,
|
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||||
build_number: build_number.to_s,
|
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||||
|
filepath: ENV.fetch("ASC_KEY_P8"),
|
||||||
)
|
)
|
||||||
|
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||||
|
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||||
|
|
||||||
|
deadline = Time.now + (30 * 60)
|
||||||
|
loop do
|
||||||
|
builds = Spaceship::ConnectAPI::Build.all(
|
||||||
|
app_id: app.id,
|
||||||
|
filter: { "version" => build_number.to_s },
|
||||||
|
includes: "preReleaseVersion",
|
||||||
|
)
|
||||||
|
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||||
|
state = build&.processing_state
|
||||||
|
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||||
|
break if state == "VALID"
|
||||||
|
if state == "INVALID" || state == "FAILED"
|
||||||
|
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||||
|
end
|
||||||
|
if Time.now > deadline
|
||||||
|
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
|
||||||
|
end
|
||||||
|
sleep(30)
|
||||||
|
end
|
||||||
|
|
||||||
deliver(
|
deliver(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Scale the amplitude of the thinking-tone PCM16 base64 in-place.
|
||||||
|
// Usage: node scripts/lower-thinking-tone.mjs <gain>
|
||||||
|
// Example: node scripts/lower-thinking-tone.mjs 0.3 (≈ -10 dB)
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const target = resolve(here, "../src/utils/thinking-tone.native-pcm.ts");
|
||||||
|
|
||||||
|
const gainArg = process.argv[2];
|
||||||
|
if (!gainArg) {
|
||||||
|
console.error("Usage: node scripts/lower-thinking-tone.mjs <gain>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const gain = Number(gainArg);
|
||||||
|
if (!Number.isFinite(gain) || gain < 0) {
|
||||||
|
console.error(`Invalid gain: ${gainArg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync(target, "utf8");
|
||||||
|
const match = source.match(/"([A-Za-z0-9+/=]+)"/);
|
||||||
|
if (!match) {
|
||||||
|
console.error("Could not find base64 string in target file.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const originalBase64 = match[1];
|
||||||
|
|
||||||
|
const buf = Buffer.from(originalBase64, "base64");
|
||||||
|
if (buf.byteLength % 2 !== 0) {
|
||||||
|
console.error(`PCM16 buffer has odd length ${buf.byteLength}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let peakBefore = 0;
|
||||||
|
let peakAfter = 0;
|
||||||
|
const out = Buffer.alloc(buf.byteLength);
|
||||||
|
for (let i = 0; i < buf.byteLength; i += 2) {
|
||||||
|
const sample = buf.readInt16LE(i);
|
||||||
|
peakBefore = Math.max(peakBefore, Math.abs(sample));
|
||||||
|
const scaled = Math.max(-32768, Math.min(32767, Math.round(sample * gain)));
|
||||||
|
peakAfter = Math.max(peakAfter, Math.abs(scaled));
|
||||||
|
out.writeInt16LE(scaled, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBase64 = out.toString("base64");
|
||||||
|
const updated = source.replace(originalBase64, newBase64);
|
||||||
|
writeFileSync(target, updated);
|
||||||
|
|
||||||
|
console.log(`Scaled thinking tone by ${gain}.`);
|
||||||
|
console.log(` samples: ${buf.byteLength / 2}`);
|
||||||
|
console.log(` peak abs: ${peakBefore} -> ${peakAfter}`);
|
||||||
|
console.log(` base64 len ${originalBase64.length} -> ${newBase64.length}`);
|
||||||
|
console.log(` wrote ${target}`);
|
||||||
@@ -422,7 +422,7 @@ function AppContainer({
|
|||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
|
||||||
}, [settings.theme, updateSettings]);
|
}, [settings.theme, updateSettings]);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
@@ -785,7 +785,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||||
}, [activeServerId, hosts, pathname, router]);
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import type {
|
|||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||||
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
userAttachmentsOnly,
|
||||||
|
workspaceAttachmentToSubmitAttachment,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
|
||||||
import { useClearReviewDraft } from "@/review/store";
|
import { useClearReviewDraft } from "@/review/store";
|
||||||
|
|
||||||
interface WorkspaceAttachmentBindingInput {
|
interface WorkspaceAttachmentBindingInput {
|
||||||
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWorkspaceAttachment(
|
|
||||||
attachment: ComposerAttachment | undefined,
|
|
||||||
): attachment is WorkspaceComposerAttachment {
|
|
||||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
|
||||||
}
|
|
||||||
|
|
||||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
|
||||||
return attachments.filter(
|
|
||||||
(attachment): attachment is UserComposerAttachment =>
|
|
||||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
|
||||||
if (attachment.kind === "browser_element") {
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
title: `Browser element · ${attachment.attachment.tag}`,
|
|
||||||
text: attachment.attachment.formatted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return attachment.kind === "review" ? attachment.attachment : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||||
return (
|
return (
|
||||||
<WorkspaceAttachmentPill
|
<WorkspaceAttachmentPill
|
||||||
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
|
|||||||
export const composerWorkspaceAttachment = {
|
export const composerWorkspaceAttachment = {
|
||||||
is: isWorkspaceAttachment,
|
is: isWorkspaceAttachment,
|
||||||
renderPill,
|
renderPill,
|
||||||
toSubmitAttachment,
|
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||||
userAttachmentsOnly,
|
userAttachmentsOnly,
|
||||||
useBinding: useWorkspaceAttachmentBinding,
|
useBinding: useWorkspaceAttachmentBinding,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ class FakeRequest<T = unknown> {
|
|||||||
class FakeObjectStore {
|
class FakeObjectStore {
|
||||||
constructor(private readonly onPut: (record: unknown) => void) {}
|
constructor(private readonly onPut: (record: unknown) => void) {}
|
||||||
|
|
||||||
put(record: unknown): FakeRequest<unknown> {
|
put(record: unknown): FakeRequest {
|
||||||
this.onPut(record);
|
this.onPut(record);
|
||||||
const request = new FakeRequest<unknown>();
|
const request = new FakeRequest();
|
||||||
queueMicrotask(() => request.emit("success"));
|
queueMicrotask(() => request.emit("success"));
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function runTx<T>(
|
|||||||
const request = run(store);
|
const request = run(store);
|
||||||
|
|
||||||
request.addEventListener("success", () => {
|
request.addEventListener("success", () => {
|
||||||
resolve(request.result as T);
|
resolve(request.result);
|
||||||
});
|
});
|
||||||
|
|
||||||
request.addEventListener("error", () => {
|
request.addEventListener("error", () => {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type {
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
|
||||||
|
export function isWorkspaceAttachment(
|
||||||
|
attachment: ComposerAttachment | undefined,
|
||||||
|
): attachment is WorkspaceComposerAttachment {
|
||||||
|
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userAttachmentsOnly(
|
||||||
|
attachments: readonly ComposerAttachment[],
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
return attachments.filter(
|
||||||
|
(attachment): attachment is UserComposerAttachment =>
|
||||||
|
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceAttachmentToSubmitAttachment(
|
||||||
|
attachment: ComposerAttachment,
|
||||||
|
): AgentAttachment | null {
|
||||||
|
if (attachment.kind === "browser_element") {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: `Browser element · ${attachment.attachment.tag}`,
|
||||||
|
text: attachment.attachment.formatted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return attachment.kind === "review" ? attachment.attachment : null;
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
|
|||||||
map.set(
|
map.set(
|
||||||
provider,
|
provider,
|
||||||
modelOptions.map((option) => ({
|
modelOptions.map((option) => ({
|
||||||
provider: provider as AgentProvider,
|
provider: provider,
|
||||||
id: option.id,
|
id: option.id,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { NeighborResolver } from "./agent-stream-view-data";
|
||||||
|
import {
|
||||||
|
getAssistantBlockSpacing,
|
||||||
|
isSameAssistantBlockGroup,
|
||||||
|
resolveInlineWorkingIndicatorItemId,
|
||||||
|
} from "./agent-stream-view-data";
|
||||||
|
|
||||||
|
// Minimal forward-order resolver: "below" = next item in array.
|
||||||
|
// Matches web strategy rendering order (chronological, top-to-bottom).
|
||||||
|
const forwardStrategy: NeighborResolver = {
|
||||||
|
getNeighborItem(items, index, relation) {
|
||||||
|
const neighborIndex = relation === "below" ? index + 1 : index - 1;
|
||||||
|
return items[neighborIndex];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function assistantBlock(params: {
|
||||||
|
id: string;
|
||||||
|
blockGroupId: string;
|
||||||
|
blockIndex: number;
|
||||||
|
text?: string;
|
||||||
|
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: params.id,
|
||||||
|
blockGroupId: params.blockGroupId,
|
||||||
|
blockIndex: params.blockIndex,
|
||||||
|
text: params.text ?? "",
|
||||||
|
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
||||||
|
return {
|
||||||
|
kind: "tool_call",
|
||||||
|
id,
|
||||||
|
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
payload: {
|
||||||
|
source: "orchestrator",
|
||||||
|
data: {
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: "bash",
|
||||||
|
arguments: "cmd",
|
||||||
|
result: null,
|
||||||
|
status: "executing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isSameAssistantBlockGroup", () => {
|
||||||
|
it("returns true for two assistant blocks with the same blockGroupId", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for blocks from different groups", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when one item is not an assistant_message", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const tc = toolCallBlock("tc-1");
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null neighbors", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAssistantBlockSpacing", () => {
|
||||||
|
it("returns default for non-assistant items", () => {
|
||||||
|
const tc = toolCallBlock("tc-1");
|
||||||
|
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default when no same-group neighbors exist", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactTop when the item above is in the same block group", () => {
|
||||||
|
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
|
||||||
|
"compactTop",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactBottom when the item below is in the same block group", () => {
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
|
||||||
|
"compactBottom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactBoth when both neighbors are in the same block group", () => {
|
||||||
|
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
|
||||||
|
"compactBoth",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
|
||||||
|
const tailBlock = assistantBlock({
|
||||||
|
id: "group-1:block:0",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 0,
|
||||||
|
text: "First paragraph",
|
||||||
|
});
|
||||||
|
const headBlock = assistantBlock({
|
||||||
|
id: "group-1:head",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 1,
|
||||||
|
text: "Second paragraph",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
|
||||||
|
).toBe("compactBottom");
|
||||||
|
expect(
|
||||||
|
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
|
||||||
|
).toBe("compactTop");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveInlineWorkingIndicatorItemId", () => {
|
||||||
|
it("returns null when the agent is not running", () => {
|
||||||
|
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the last assistant block id when running with a single head block", () => {
|
||||||
|
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
|
||||||
|
"group-1:head",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
|
||||||
|
const tc = toolCallBlock("tool-1");
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the footer assistant block when history and streaming head coexist", () => {
|
||||||
|
const historyBlock = assistantBlock({
|
||||||
|
id: "group-1:block:0",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 0,
|
||||||
|
});
|
||||||
|
const streamingBlock = assistantBlock({
|
||||||
|
id: "group-2:head",
|
||||||
|
blockGroupId: "group-2",
|
||||||
|
blockIndex: 0,
|
||||||
|
});
|
||||||
|
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
|
||||||
|
"group-2:head",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveInlineWorkingIndicatorItemId(
|
||||||
|
"running",
|
||||||
|
[historyBlock, streamingBlock],
|
||||||
|
forwardStrategy,
|
||||||
|
),
|
||||||
|
).toBe("group-2:head");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
|
||||||
|
export function isSameAssistantBlockGroup(params: {
|
||||||
|
item: StreamItem | null | undefined;
|
||||||
|
other: StreamItem | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
params.item?.kind === "assistant_message" &&
|
||||||
|
params.other?.kind === "assistant_message" &&
|
||||||
|
params.item.blockGroupId !== undefined &&
|
||||||
|
params.item.blockGroupId === params.other.blockGroupId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssistantBlockSpacing(params: {
|
||||||
|
item: StreamItem;
|
||||||
|
aboveItem: StreamItem | null | undefined;
|
||||||
|
belowItem: StreamItem | null | undefined;
|
||||||
|
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||||
|
if (params.item.kind !== "assistant_message") {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||||
|
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||||
|
if (compactTop && compactBottom) return "compactBoth";
|
||||||
|
if (compactTop) return "compactTop";
|
||||||
|
if (compactBottom) return "compactBottom";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborResolver {
|
||||||
|
getNeighborItem(
|
||||||
|
items: StreamItem[],
|
||||||
|
index: number,
|
||||||
|
relation: "above" | "below",
|
||||||
|
): StreamItem | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// null → auxiliary working indicator; non-null → inline footer on that block.
|
||||||
|
export function resolveInlineWorkingIndicatorItemId(
|
||||||
|
status: string,
|
||||||
|
liveHeadItems: StreamItem[],
|
||||||
|
strategy: NeighborResolver,
|
||||||
|
): string | null {
|
||||||
|
if (status !== "running") return null;
|
||||||
|
const footerItem = liveHeadItems.find((item, index, items) => {
|
||||||
|
if (item.kind !== "assistant_message") return false;
|
||||||
|
return strategy.getNeighborItem(items, index, "below") === undefined;
|
||||||
|
});
|
||||||
|
return footerItem?.id ?? null;
|
||||||
|
}
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
import { AgentStreamView } from "./agent-stream-view";
|
|
||||||
|
|
||||||
const assistantMessageCalls = vi.hoisted(
|
|
||||||
() => [] as Array<{ message: string; spacing: string | undefined }>,
|
|
||||||
);
|
|
||||||
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
|
|
||||||
|
|
||||||
const mockSessionState = vi.hoisted(() => ({
|
|
||||||
sessions: {
|
|
||||||
server: {
|
|
||||||
client: null,
|
|
||||||
agentStreamHead: new Map<string, StreamItem[]>(),
|
|
||||||
workspaces: new Map(),
|
|
||||||
agentTimelineCursor: new Map(),
|
|
||||||
agentTimelineHasOlder: new Map(),
|
|
||||||
agentTimelineOlderFetchInFlight: new Map(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setAgentTimelineOlderFetchInFlight: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (
|
|
||||||
factory: (theme: {
|
|
||||||
borderRadius: Record<string, number>;
|
|
||||||
borderWidth: Record<number, number>;
|
|
||||||
colors: Record<string, string>;
|
|
||||||
fontSize: Record<string, number>;
|
|
||||||
fontWeight: Record<string, string>;
|
|
||||||
shadow: Record<string, object>;
|
|
||||||
spacing: Record<number, number>;
|
|
||||||
}) => unknown,
|
|
||||||
) =>
|
|
||||||
factory({
|
|
||||||
borderRadius: {
|
|
||||||
full: 9999,
|
|
||||||
md: 6,
|
|
||||||
},
|
|
||||||
borderWidth: {
|
|
||||||
1: 1,
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
surface0: "#000",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
border: "#333",
|
|
||||||
borderAccent: "#444",
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
sm: 14,
|
|
||||||
base: 16,
|
|
||||||
xs: 12,
|
|
||||||
},
|
|
||||||
fontWeight: {
|
|
||||||
normal: "normal",
|
|
||||||
},
|
|
||||||
shadow: {
|
|
||||||
sm: {},
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
1: 4,
|
|
||||||
2: 8,
|
|
||||||
3: 12,
|
|
||||||
4: 16,
|
|
||||||
12: 48,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
|
||||||
withUnistyles: (Component: unknown) => Component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
default: {
|
|
||||||
View: ({ children, ...props }: { children?: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement("div", props, children),
|
|
||||||
},
|
|
||||||
Easing: { linear: vi.fn() },
|
|
||||||
FadeIn: { duration: vi.fn(() => undefined) },
|
|
||||||
FadeOut: { duration: vi.fn(() => undefined) },
|
|
||||||
cancelAnimation: vi.fn(),
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
withRepeat: (value: unknown) => value,
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
const Icon = () => ReactModule.createElement("span");
|
|
||||||
return {
|
|
||||||
Check: Icon,
|
|
||||||
ChevronDown: Icon,
|
|
||||||
X: Icon,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./message", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
ActivityLog: () => null,
|
|
||||||
AssistantMessage: (props: { message: string; spacing?: string }) => {
|
|
||||||
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
|
|
||||||
return ReactModule.createElement("div", {
|
|
||||||
"data-message": props.message,
|
|
||||||
"data-spacing": props.spacing ?? "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
CompactionMarker: () => null,
|
|
||||||
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement(ReactModule.Fragment, null, children),
|
|
||||||
SpeakMessage: () => null,
|
|
||||||
TodoListCard: () => null,
|
|
||||||
ToolCall: () => null,
|
|
||||||
TurnCopyButton: (props: { getContent: () => string }) => {
|
|
||||||
turnCopyButtonCalls.push(props);
|
|
||||||
return ReactModule.createElement("button", {
|
|
||||||
"data-testid": "turn-copy-button",
|
|
||||||
type: "button",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
UserMessage: () => null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./tool-call-sheet", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement(ReactModule.Fragment, null, children),
|
|
||||||
useToolCallSheet: () => ({ open: vi.fn() }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
|
|
||||||
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
|
||||||
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
|
|
||||||
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
|
|
||||||
vi.mock("@/hooks/use-file-explorer-actions", () => ({
|
|
||||||
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/stores/panel-store", () => ({
|
|
||||||
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
|
||||||
selector({
|
|
||||||
openFileExplorerForCheckout: vi.fn(),
|
|
||||||
setExplorerTabForCheckout: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: Object.assign(
|
|
||||||
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
|
|
||||||
{
|
|
||||||
getState: () => mockSessionState,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
|
|
||||||
|
|
||||||
function assistantBlock(params: {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
blockIndex: number;
|
|
||||||
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
|
||||||
return {
|
|
||||||
kind: "assistant_message",
|
|
||||||
id: params.id,
|
|
||||||
blockGroupId: "group-1",
|
|
||||||
blockIndex: params.blockIndex,
|
|
||||||
text: params.text,
|
|
||||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
|
||||||
return {
|
|
||||||
kind: "tool_call",
|
|
||||||
id,
|
|
||||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
|
||||||
payload: {
|
|
||||||
source: "orchestrator",
|
|
||||||
data: {
|
|
||||||
toolCallId: id,
|
|
||||||
toolName: "bash",
|
|
||||||
arguments: "npm test",
|
|
||||||
result: null,
|
|
||||||
status: "executing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AgentStreamView", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement | null = null;
|
|
||||||
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
originalScrollTo = HTMLElement.prototype.scrollTo;
|
|
||||||
HTMLElement.prototype.scrollTo = vi.fn();
|
|
||||||
assistantMessageCalls.length = 0;
|
|
||||||
turnCopyButtonCalls.length = 0;
|
|
||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
if (originalScrollTo) {
|
|
||||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
|
||||||
} else {
|
|
||||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
|
||||||
}
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compacts assistant block spacing across the history/live-head boundary", () => {
|
|
||||||
const tailBlock = assistantBlock({
|
|
||||||
id: "group-1:block:0",
|
|
||||||
text: "First paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Second paragraph",
|
|
||||||
blockIndex: 1,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "idle",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
const streamItems = [tailBlock];
|
|
||||||
const pendingPermissions = new Map();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems,
|
|
||||||
pendingPermissions,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
|
|
||||||
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
|
|
||||||
|
|
||||||
expect(tailCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(headCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(tailCalls.map((call) => call.spacing)).toEqual(
|
|
||||||
Array.from({ length: tailCalls.length }, () => "compactBottom"),
|
|
||||||
);
|
|
||||||
expect(headCalls.map((call) => call.spacing)).toEqual(
|
|
||||||
Array.from({ length: headCalls.length }, () => "compactTop"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders running dots in the assistant turn footer when live text is streaming", () => {
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Streaming paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).toBeNull();
|
|
||||||
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only renders running dots on the live assistant row", () => {
|
|
||||||
const tailBlock = assistantBlock({
|
|
||||||
id: "group-1:block:0",
|
|
||||||
text: "History paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-2:head",
|
|
||||||
text: "Streaming paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [tailBlock],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the auxiliary running dots when there is no live assistant row", () => {
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the running footer with the copy button when the assistant turn idles", () => {
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Complete paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "idle",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
|
|
||||||
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
|
|
||||||
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
|
|
||||||
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -71,6 +71,11 @@ import {
|
|||||||
type StreamSegmentRenderers,
|
type StreamSegmentRenderers,
|
||||||
type StreamViewportHandle,
|
type StreamViewportHandle,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
|
import {
|
||||||
|
getAssistantBlockSpacing,
|
||||||
|
isSameAssistantBlockGroup,
|
||||||
|
resolveInlineWorkingIndicatorItemId,
|
||||||
|
} from "./agent-stream-view-data";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
@@ -92,43 +97,6 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
|||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
|
|
||||||
const isSameAssistantBlockGroup = (params: {
|
|
||||||
item: StreamItem | null | undefined;
|
|
||||||
other: StreamItem | null | undefined;
|
|
||||||
}) =>
|
|
||||||
params.item?.kind === "assistant_message" &&
|
|
||||||
params.other?.kind === "assistant_message" &&
|
|
||||||
params.item.blockGroupId !== undefined &&
|
|
||||||
params.item.blockGroupId === params.other.blockGroupId;
|
|
||||||
|
|
||||||
const getAssistantBlockSpacing = (params: {
|
|
||||||
item: StreamItem;
|
|
||||||
aboveItem: StreamItem | null | undefined;
|
|
||||||
belowItem: StreamItem | null | undefined;
|
|
||||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" => {
|
|
||||||
if (params.item.kind !== "assistant_message") {
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
const compactTop = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.aboveItem,
|
|
||||||
});
|
|
||||||
const compactBottom = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.belowItem,
|
|
||||||
});
|
|
||||||
if (compactTop && compactBottom) {
|
|
||||||
return "compactBoth";
|
|
||||||
}
|
|
||||||
if (compactTop) {
|
|
||||||
return "compactTop";
|
|
||||||
}
|
|
||||||
if (compactBottom) {
|
|
||||||
return "compactBottom";
|
|
||||||
}
|
|
||||||
return "default";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StreamItemBoundarySeams {
|
interface StreamItemBoundarySeams {
|
||||||
aboveItem?: StreamItem | null;
|
aboveItem?: StreamItem | null;
|
||||||
belowItem?: StreamItem | null;
|
belowItem?: StreamItem | null;
|
||||||
@@ -301,25 +269,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
const inlineWorkingIndicatorItemId = useMemo(() => {
|
const inlineWorkingIndicatorItemId = useMemo(
|
||||||
if (agent.status !== "running") {
|
() =>
|
||||||
return null;
|
resolveInlineWorkingIndicatorItemId(
|
||||||
}
|
agent.status,
|
||||||
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
|
baseRenderModel.segments.liveHead,
|
||||||
if (item.kind !== "assistant_message") {
|
streamRenderStrategy,
|
||||||
return false;
|
),
|
||||||
}
|
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
||||||
return (
|
);
|
||||||
getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
}) === undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return footerItem?.id ?? null;
|
|
||||||
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ export function BrowserPane({
|
|||||||
};
|
};
|
||||||
const handleFaviconUpdated = (event: Event) => {
|
const handleFaviconUpdated = (event: Event) => {
|
||||||
const favicons = Array.isArray((event as Event & { favicons?: unknown[] }).favicons)
|
const favicons = Array.isArray((event as Event & { favicons?: unknown[] }).favicons)
|
||||||
? (((event as Event & { favicons?: string[] }).favicons as string[] | undefined) ?? [])
|
? ((event as Event & { favicons?: string[] }).favicons ?? [])
|
||||||
: [];
|
: [];
|
||||||
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
|
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ describe("combined model selector helpers", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
||||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the selected trigger label model-only", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ export function CombinedModelSelector({
|
|||||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||||
const providers = Array.from(allProviderModels.keys());
|
const providers = Array.from(allProviderModels.keys());
|
||||||
if (providers.length !== 1) return null;
|
if (providers.length !== 1) return null;
|
||||||
const providerId = providers[0]!;
|
const providerId = providers[0];
|
||||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||||
return { kind: "provider", providerId, providerLabel: label };
|
return { kind: "provider", providerId, providerLabel: label };
|
||||||
}, [allProviderModels, providerDefinitions]);
|
}, [allProviderModels, providerDefinitions]);
|
||||||
@@ -655,7 +655,7 @@ export function CombinedModelSelector({
|
|||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import type {
|
||||||
|
AttachmentMetadata,
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
cancelComposerAgent,
|
||||||
|
dispatchComposerAgentMessage,
|
||||||
|
editQueuedComposerMessage,
|
||||||
|
findGithubItemByOption,
|
||||||
|
isAttachmentSelectedForGithubItem,
|
||||||
|
openComposerAttachment,
|
||||||
|
pickAndPersistImages,
|
||||||
|
queueComposerMessage,
|
||||||
|
removeComposerAttachmentAtIndex,
|
||||||
|
sendQueuedComposerMessageNow,
|
||||||
|
toggleGithubAttachment,
|
||||||
|
type AgentStreamWriter,
|
||||||
|
type AttachmentPersister,
|
||||||
|
type ComposerCancelClient,
|
||||||
|
type ComposerSendClient,
|
||||||
|
type QueueWriter,
|
||||||
|
type QueuedComposerMessage,
|
||||||
|
} from "./composer-actions";
|
||||||
|
|
||||||
|
const imageMetadata: AttachmentMetadata = {
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "img-1",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
byteSize: 42,
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueItem: GitHubSearchItem = {
|
||||||
|
kind: "issue",
|
||||||
|
number: 101,
|
||||||
|
title: "Fix composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/issues/101",
|
||||||
|
state: "open",
|
||||||
|
body: "Issue body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: null,
|
||||||
|
headRefName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prItem: GitHubSearchItem = {
|
||||||
|
kind: "pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
state: "open",
|
||||||
|
body: "PR body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
};
|
||||||
|
|
||||||
|
function imageWithId(id: string): AttachmentMetadata {
|
||||||
|
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||||
|
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||||
|
type: "review",
|
||||||
|
mimeType: "application/paseo-review",
|
||||||
|
cwd: "/repo",
|
||||||
|
mode: "uncommitted",
|
||||||
|
baseRef: null,
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
filePath: "src/example.ts",
|
||||||
|
side: "new",
|
||||||
|
lineNumber: 41,
|
||||||
|
body,
|
||||||
|
context: {
|
||||||
|
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||||
|
targetLine: {
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber: 41,
|
||||||
|
type: "add",
|
||||||
|
content: "const value = newValue;",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber: 41,
|
||||||
|
type: "add",
|
||||||
|
content: "const value = newValue;",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: "review",
|
||||||
|
reviewDraftKey: `review:${body}`,
|
||||||
|
commentCount: 1,
|
||||||
|
attachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserElementWorkspaceAttachment(): Extract<
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
{ kind: "browser_element" }
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
kind: "browser_element",
|
||||||
|
attachment: {
|
||||||
|
url: "https://example.com/page",
|
||||||
|
selector: "button.primary",
|
||||||
|
tag: "button",
|
||||||
|
text: "Save",
|
||||||
|
outerHTML: '<button class="primary">Save</button>',
|
||||||
|
computedStyles: { display: "flex" },
|
||||||
|
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||||
|
reactSource: null,
|
||||||
|
parentChain: ["form.settings"],
|
||||||
|
children: [],
|
||||||
|
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakePersister(): AttachmentPersister & {
|
||||||
|
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||||
|
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||||
|
deletedBatches: AttachmentMetadata[][];
|
||||||
|
} {
|
||||||
|
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||||
|
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||||
|
const deletedBatches: AttachmentMetadata[][] = [];
|
||||||
|
return {
|
||||||
|
blobCalls,
|
||||||
|
fileUriCalls,
|
||||||
|
deletedBatches,
|
||||||
|
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||||
|
blobCalls.push({ blob, mimeType, fileName });
|
||||||
|
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||||
|
},
|
||||||
|
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||||
|
fileUriCalls.push({ uri, mimeType, fileName });
|
||||||
|
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||||
|
},
|
||||||
|
deleteAttachments: (metadata) => {
|
||||||
|
deletedBatches.push(metadata);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeSendCall {
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
options: {
|
||||||
|
messageId: string;
|
||||||
|
images: Array<{ data: string; mimeType: string }>;
|
||||||
|
attachments: AgentAttachment[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeSendClient(
|
||||||
|
options: { rejection?: Error } = {},
|
||||||
|
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||||
|
const calls: FakeSendCall[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
sendAgentMessage: async (agentId, text, opts) => {
|
||||||
|
calls.push({ agentId, text, options: opts });
|
||||||
|
if (options.rejection) {
|
||||||
|
throw options.rejection;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeStream extends AgentStreamWriter {
|
||||||
|
head: Map<string, StreamItem[]>;
|
||||||
|
tail: Map<string, StreamItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||||
|
const fake: FakeStream = {
|
||||||
|
head: new Map(initialHead),
|
||||||
|
tail: new Map(),
|
||||||
|
getHead: (agentId) => fake.head.get(agentId),
|
||||||
|
setHead: (updater) => {
|
||||||
|
fake.head = updater(fake.head);
|
||||||
|
},
|
||||||
|
setTail: (updater) => {
|
||||||
|
fake.tail = updater(fake.tail);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeQueue(
|
||||||
|
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||||
|
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||||
|
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||||
|
state: new Map(initial),
|
||||||
|
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||||
|
write: (updater) => {
|
||||||
|
fake.state = updater(fake.state);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||||
|
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||||
|
|
||||||
|
describe("cancelComposerAgent", () => {
|
||||||
|
function baseInput(): {
|
||||||
|
client: ComposerCancelClient & { canceledIds: string[] };
|
||||||
|
agentId: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
isCancellingAgent: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
} {
|
||||||
|
const canceledIds: string[] = [];
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
canceledIds,
|
||||||
|
cancelAgent: async (id) => {
|
||||||
|
canceledIds.push(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentId: "agent",
|
||||||
|
isAgentRunning: true,
|
||||||
|
isCancellingAgent: false,
|
||||||
|
isConnected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent(input);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the agent is not running", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the agent is already being canceled", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when disconnected or the client is null", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||||
|
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickAndPersistImages", () => {
|
||||||
|
it("returns [] when the picker yields nothing", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => null,
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(persister.blobCalls).toEqual([]);
|
||||||
|
expect(persister.fileUriCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const blob = new Blob(["image"]);
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => [
|
||||||
|
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||||
|
],
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||||
|
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => [
|
||||||
|
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||||
|
],
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(persister.fileUriCalls).toEqual([
|
||||||
|
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||||
|
]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dispatchComposerAgentMessage", () => {
|
||||||
|
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const image = imageWithId("img-2");
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "send attachments",
|
||||||
|
attachments: [
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls).toHaveLength(1);
|
||||||
|
const [call] = client.calls;
|
||||||
|
expect(call.agentId).toBe("agent");
|
||||||
|
expect(call.text).toBe("send attachments");
|
||||||
|
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||||
|
expect(call.options.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "github_pr",
|
||||||
|
mimeType: "application/github-pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
body: "PR body",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(stream.head.get("agent")).toBeUndefined();
|
||||||
|
const tail = stream.tail.get("agent");
|
||||||
|
expect(tail).toHaveLength(1);
|
||||||
|
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||||
|
expect(userMessage.kind).toBe("user_message");
|
||||||
|
expect(userMessage.text).toBe("send attachments");
|
||||||
|
expect(userMessage.images).toEqual([image]);
|
||||||
|
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||||
|
expect(userMessage.id).toBe(call.options.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to the existing head when one is present", async () => {
|
||||||
|
const existingItem: StreamItem = {
|
||||||
|
kind: "user_message",
|
||||||
|
id: "prior",
|
||||||
|
text: "prior",
|
||||||
|
timestamp: new Date(0),
|
||||||
|
};
|
||||||
|
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "next message",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stream.head.get("agent")).toHaveLength(2);
|
||||||
|
expect(stream.tail.get("agent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "plain message",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options).toMatchObject({
|
||||||
|
images: [],
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "review this",
|
||||||
|
attachments: [review],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||||
|
expect(client.calls[0]?.options.images).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const browserElement = browserElementWorkspaceAttachment();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "inspect element",
|
||||||
|
attachments: [browserElement],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: "Browser element · button",
|
||||||
|
text: browserElement.attachment.formatted,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queueComposerMessage", () => {
|
||||||
|
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const result = queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: " draft ",
|
||||||
|
attachments: [],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.queued?.text).toBe("draft");
|
||||||
|
expect(queue.state.get("agent")).toEqual([
|
||||||
|
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not queue an empty message with no attachments", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const result = queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: " ",
|
||||||
|
attachments: [],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
expect(result.queued).toBeNull();
|
||||||
|
expect(queue.state.get("agent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||||
|
const image = imageWithId("img-queue");
|
||||||
|
queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: "queue this",
|
||||||
|
attachments: [{ kind: "image", metadata: image }, review],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
review,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editQueuedComposerMessage", () => {
|
||||||
|
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||||
|
);
|
||||||
|
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(queue.state.get("agent")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||||
|
const image = imageWithId("img-queued-edit");
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
text: "queued draft",
|
||||||
|
attachments: [{ kind: "image", metadata: image }, review],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||||
|
expect(result).toEqual({
|
||||||
|
text: "queued draft",
|
||||||
|
attachments: [{ kind: "image", metadata: image }],
|
||||||
|
});
|
||||||
|
expect(queue.state.get("agent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendQueuedComposerMessageNow", () => {
|
||||||
|
it("returns missing without submitting when the message id is gone", async () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async (input) => {
|
||||||
|
submitted.push(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "missing" });
|
||||||
|
expect(submitted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the queued entry and submits its text + attachments", async () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||||
|
);
|
||||||
|
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async (input) => {
|
||||||
|
submitted.push(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "submitted" });
|
||||||
|
expect(queue.state.get("agent")).toEqual([]);
|
||||||
|
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent",
|
||||||
|
[
|
||||||
|
{ id: "msg-1", text: "first", attachments: [] },
|
||||||
|
{ id: "msg-2", text: "second", attachments: [] },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||||
|
const state = queue.state.get("agent");
|
||||||
|
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeComposerAttachmentAtIndex", () => {
|
||||||
|
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||||
|
const image = imageWithId("img-remove");
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const next = removeComposerAttachmentAtIndex({
|
||||||
|
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||||
|
index: 0,
|
||||||
|
deleteAttachments: persister.deleteAttachments,
|
||||||
|
});
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
expect(persister.deletedBatches).toEqual([[image]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const next = removeComposerAttachmentAtIndex({
|
||||||
|
attachments: [
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
] satisfies UserComposerAttachment[],
|
||||||
|
index: 0,
|
||||||
|
deleteAttachments: persister.deleteAttachments,
|
||||||
|
});
|
||||||
|
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||||
|
expect(persister.deletedBatches).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openComposerAttachment", () => {
|
||||||
|
it("opens the lightbox for image attachments", () => {
|
||||||
|
const image = imageWithId("img-body");
|
||||||
|
const lightboxCalls: AttachmentMetadata[] = [];
|
||||||
|
const externalUrlCalls: string[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: { kind: "image", metadata: image },
|
||||||
|
setLightboxMetadata: (metadata) => {
|
||||||
|
lightboxCalls.push(metadata);
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: () => false,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
externalUrlCalls.push(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lightboxCalls).toEqual([image]);
|
||||||
|
expect(externalUrlCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates workspace review attachments to the workspace opener", () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Open me.");
|
||||||
|
const workspaceCalls: ComposerAttachment[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: review,
|
||||||
|
setLightboxMetadata: () => {
|
||||||
|
throw new Error("unexpected lightbox call");
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: ({ attachment }) => {
|
||||||
|
workspaceCalls.push(attachment);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
openExternalUrl: () => {
|
||||||
|
throw new Error("unexpected external url call");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(workspaceCalls).toEqual([review]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens GitHub item URLs through the external url opener", () => {
|
||||||
|
const externalUrlCalls: string[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: { kind: "github_issue", item: issueItem },
|
||||||
|
setLightboxMetadata: () => {
|
||||||
|
throw new Error("unexpected lightbox call");
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: () => false,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
externalUrlCalls.push(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleGithubAttachment", () => {
|
||||||
|
it("appends a GitHub issue when not already attached", () => {
|
||||||
|
const next = toggleGithubAttachment([], issueItem);
|
||||||
|
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a GitHub PR when not already attached", () => {
|
||||||
|
const next = toggleGithubAttachment([], prItem);
|
||||||
|
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing GitHub item with the same kind+number", () => {
|
||||||
|
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other items with different kind or number", () => {
|
||||||
|
const start: UserComposerAttachment[] = [
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
];
|
||||||
|
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||||
|
const next = toggleGithubAttachment(start, otherIssue);
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
{ kind: "github_issue", item: otherIssue },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||||
|
it("locates items via their composite kind:number id", () => {
|
||||||
|
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||||
|
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||||
|
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||||
|
const attachments: ComposerAttachment[] = [
|
||||||
|
{ kind: "image", metadata: imageWithId("img-x") },
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
reviewWorkspaceAttachment("ignored"),
|
||||||
|
];
|
||||||
|
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||||
|
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import type {
|
||||||
|
AttachmentMetadata,
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
userAttachmentsOnly,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||||
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
|
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||||
|
|
||||||
|
export interface QueuedComposerMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentPersister {
|
||||||
|
persistFromBlob: (input: {
|
||||||
|
blob: Blob;
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string | null;
|
||||||
|
}) => Promise<AttachmentMetadata>;
|
||||||
|
persistFromFileUri: (input: {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string | null;
|
||||||
|
}) => Promise<AttachmentMetadata>;
|
||||||
|
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerSendClient {
|
||||||
|
sendAgentMessage: (
|
||||||
|
agentId: string,
|
||||||
|
text: string,
|
||||||
|
options: {
|
||||||
|
messageId: string;
|
||||||
|
images: Array<{ data: string; mimeType: string }>;
|
||||||
|
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerCancelClient {
|
||||||
|
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStreamWriter {
|
||||||
|
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||||
|
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||||
|
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueWriter {
|
||||||
|
read: (agentId: string) => QueuedComposerMessage[];
|
||||||
|
write: (
|
||||||
|
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickAndPersistImages(input: {
|
||||||
|
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||||
|
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||||
|
}): Promise<AttachmentMetadata[]> {
|
||||||
|
const result = await input.pickImages();
|
||||||
|
if (!result?.length) return [];
|
||||||
|
return await Promise.all(
|
||||||
|
result.map(async (picked) => {
|
||||||
|
const fileName = picked.fileName ?? null;
|
||||||
|
const mimeType = picked.mimeType || "image/jpeg";
|
||||||
|
if (picked.source.kind === "blob") {
|
||||||
|
return await input.persister.persistFromBlob({
|
||||||
|
blob: picked.source.blob,
|
||||||
|
mimeType,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await input.persister.persistFromFileUri({
|
||||||
|
uri: picked.source.uri,
|
||||||
|
mimeType,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||||
|
attachments: T[];
|
||||||
|
index: number;
|
||||||
|
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||||
|
}): T[] {
|
||||||
|
const removed = input.attachments[input.index];
|
||||||
|
if (removed?.kind === "image") {
|
||||||
|
void input.deleteAttachments([removed.metadata]);
|
||||||
|
}
|
||||||
|
return input.attachments.filter((_, i) => i !== input.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelComposerAgentInput {
|
||||||
|
client: ComposerCancelClient | null;
|
||||||
|
agentId: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
isCancellingAgent: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||||
|
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||||
|
if (!input.isConnected || !input.client) return false;
|
||||||
|
void input.client.cancelAgent(input.agentId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchComposerAgentMessageInput {
|
||||||
|
client: ComposerSendClient;
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
encodeImages: (
|
||||||
|
images: AttachmentMetadata[],
|
||||||
|
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||||
|
stream: AgentStreamWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchComposerAgentMessage(
|
||||||
|
input: DispatchComposerAgentMessageInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||||
|
const messageId = generateMessageId();
|
||||||
|
const userMessage: StreamItem = {
|
||||||
|
kind: "user_message",
|
||||||
|
id: messageId,
|
||||||
|
text: input.text,
|
||||||
|
timestamp: new Date(),
|
||||||
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
|
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||||
|
};
|
||||||
|
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||||
|
const imagesData = await input.encodeImages(wirePayload.images);
|
||||||
|
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||||
|
messageId,
|
||||||
|
images: imagesData ?? [],
|
||||||
|
attachments: wirePayload.attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUserMessageToStream(
|
||||||
|
agentId: string,
|
||||||
|
userMessage: StreamItem,
|
||||||
|
stream: AgentStreamWriter,
|
||||||
|
): void {
|
||||||
|
const head = stream.getHead(agentId);
|
||||||
|
if (head && head.length > 0) {
|
||||||
|
stream.setHead((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream.setTail((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueComposerMessageInput {
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
queue: QueueWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueComposerMessageResult {
|
||||||
|
queued: QueuedComposerMessage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||||
|
const trimmed = input.text.trim();
|
||||||
|
if (!trimmed && input.attachments.length === 0) {
|
||||||
|
return { queued: null };
|
||||||
|
}
|
||||||
|
const item: QueuedComposerMessage = {
|
||||||
|
id: generateMessageId(),
|
||||||
|
text: trimmed,
|
||||||
|
attachments: input.attachments,
|
||||||
|
};
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return { queued: item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditQueuedComposerMessageInput {
|
||||||
|
agentId: string;
|
||||||
|
messageId: string;
|
||||||
|
queue: QueueWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditQueuedComposerMessageResult {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editQueuedComposerMessage(
|
||||||
|
input: EditQueuedComposerMessageInput,
|
||||||
|
): EditQueuedComposerMessageResult | null {
|
||||||
|
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||||
|
if (!item) return null;
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(
|
||||||
|
input.agentId,
|
||||||
|
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
text: item.text,
|
||||||
|
attachments: userAttachmentsOnly(item.attachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendQueuedComposerMessageNowInput {
|
||||||
|
agentId: string;
|
||||||
|
messageId: string;
|
||||||
|
queue: QueueWriter;
|
||||||
|
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendQueuedComposerMessageNowResult =
|
||||||
|
| { status: "missing" }
|
||||||
|
| { status: "submitted" }
|
||||||
|
| { status: "failed"; errorMessage: string };
|
||||||
|
|
||||||
|
export async function sendQueuedComposerMessageNow(
|
||||||
|
input: SendQueuedComposerMessageNowInput,
|
||||||
|
): Promise<SendQueuedComposerMessageNowResult> {
|
||||||
|
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||||
|
if (!item) return { status: "missing" };
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(
|
||||||
|
input.agentId,
|
||||||
|
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||||
|
return { status: "submitted" };
|
||||||
|
} catch (error) {
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenComposerAttachmentInput {
|
||||||
|
attachment: ComposerAttachment;
|
||||||
|
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||||
|
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||||
|
openExternalUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||||
|
if (input.attachment.kind === "image") {
|
||||||
|
input.setLightboxMetadata(input.attachment.metadata);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWorkspaceAttachment(input.attachment)) {
|
||||||
|
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.openExternalUrl(input.attachment.item.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||||
|
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGithubAttachment(
|
||||||
|
current: UserComposerAttachment[],
|
||||||
|
item: GitHubSearchItem,
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
const matches = (attachment: UserComposerAttachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number;
|
||||||
|
if (current.some(matches)) {
|
||||||
|
return current.filter((attachment) => !matches(attachment));
|
||||||
|
}
|
||||||
|
return [...current, buildGithubAttachment(item)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGithubItemByOption(
|
||||||
|
items: readonly GitHubSearchItem[],
|
||||||
|
optionId: string,
|
||||||
|
): GitHubSearchItem | undefined {
|
||||||
|
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttachmentSelectedForGithubItem(
|
||||||
|
current: readonly ComposerAttachment[],
|
||||||
|
item: GitHubSearchItem,
|
||||||
|
): boolean {
|
||||||
|
return userAttachmentsOnly(current).some(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
workspaceAttachmentToSubmitAttachment,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
if (isWorkspaceAttachment(attachment)) {
|
||||||
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||||
if (workspaceAttachment) {
|
if (workspaceAttachment) {
|
||||||
reviewAttachments.push(workspaceAttachment);
|
reviewAttachments.push(workspaceAttachment);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
|||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
|
||||||
import {
|
import {
|
||||||
AgentStatusBar,
|
AgentStatusBar,
|
||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
import { ContextWindowMeter } from "./context-window-meter";
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
MessageInput,
|
MessageInput,
|
||||||
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
import {
|
||||||
|
cancelComposerAgent,
|
||||||
|
dispatchComposerAgentMessage,
|
||||||
|
editQueuedComposerMessage,
|
||||||
|
findGithubItemByOption,
|
||||||
|
isAttachmentSelectedForGithubItem,
|
||||||
|
openComposerAttachment,
|
||||||
|
pickAndPersistImages,
|
||||||
|
queueComposerMessage,
|
||||||
|
removeComposerAttachmentAtIndex,
|
||||||
|
sendQueuedComposerMessageNow,
|
||||||
|
toggleGithubAttachment,
|
||||||
|
type AgentStreamWriter,
|
||||||
|
type QueueWriter,
|
||||||
|
type QueuedComposerMessage,
|
||||||
|
} from "@/components/composer-actions";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -79,17 +93,12 @@ import type {
|
|||||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
|
|
||||||
interface QueuedMessage {
|
type QueuedMessage = QueuedComposerMessage;
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttachmentListUpdater =
|
type AttachmentListUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -128,52 +137,18 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
|||||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickAndPersistImages(
|
|
||||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
|
|
||||||
): Promise<ImageAttachment[]> {
|
|
||||||
const result = await pickImages();
|
|
||||||
if (!result?.length) return [];
|
|
||||||
return await Promise.all(
|
|
||||||
result.map(async (pickedImage) => {
|
|
||||||
if (pickedImage.source.kind === "blob") {
|
|
||||||
return await persistAttachmentFromBlob({
|
|
||||||
blob: pickedImage.source.blob,
|
|
||||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
|
||||||
fileName: pickedImage.fileName ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await persistAttachmentFromFileUri({
|
|
||||||
uri: pickedImage.source.uri,
|
|
||||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
|
||||||
fileName: pickedImage.fileName ?? null,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
|
|
||||||
const removed = prev[index];
|
|
||||||
if (removed?.kind === "image") {
|
|
||||||
void deleteAttachments([removed.metadata]);
|
|
||||||
}
|
|
||||||
return prev.filter((_, i) => i !== index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||||
const disabled =
|
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||||
!isConnected || isCancellingAgent ? (styles.buttonDisabled as object) : undefined;
|
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||||
return [styles.cancelButton as object, disabled].filter((value): value is object =>
|
|
||||||
Boolean(value),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRealtimeVoiceButtonStyle(
|
function buildRealtimeVoiceButtonStyle(
|
||||||
hovered: boolean | undefined,
|
hovered: boolean | undefined,
|
||||||
voiceButtonDisabled: boolean,
|
voiceButtonDisabled: boolean,
|
||||||
): object[] {
|
): object[] {
|
||||||
const hoveredStyle = hovered ? (styles.iconButtonHovered as object) : undefined;
|
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||||
const disabledStyle = voiceButtonDisabled ? (styles.buttonDisabled as object) : undefined;
|
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||||
return [styles.realtimeVoiceButton as object, hoveredStyle, disabledStyle].filter(
|
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
|
||||||
(value): value is object => Boolean(value),
|
(value): value is object => Boolean(value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
|||||||
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findGithubItemByOption(
|
|
||||||
items: readonly GitHubSearchItem[],
|
|
||||||
optionId: string,
|
|
||||||
): GitHubSearchItem | undefined {
|
|
||||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAttachmentSelectedForGithubItem(
|
|
||||||
current: readonly ComposerAttachment[],
|
|
||||||
item: GitHubSearchItem,
|
|
||||||
): boolean {
|
|
||||||
return composerWorkspaceAttachment
|
|
||||||
.userAttachmentsOnly(current)
|
|
||||||
.some(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind !== "image" &&
|
|
||||||
attachment.item.kind === item.kind &&
|
|
||||||
attachment.item.number === item.number,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
|
||||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGithubAttachment(
|
|
||||||
current: UserComposerAttachment[],
|
|
||||||
item: GitHubSearchItem,
|
|
||||||
): UserComposerAttachment[] {
|
|
||||||
const matches = (attachment: UserComposerAttachment) =>
|
|
||||||
attachment.kind !== "image" &&
|
|
||||||
attachment.item.kind === item.kind &&
|
|
||||||
attachment.item.number === item.number;
|
|
||||||
if (current.some(matches)) {
|
|
||||||
return current.filter((attachment) => !matches(attachment));
|
|
||||||
}
|
|
||||||
return [...current, buildGithubAttachment(item)];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RenderAttachmentPreviewListArgs {
|
interface RenderAttachmentPreviewListArgs {
|
||||||
selectedAttachments: ComposerAttachment[];
|
selectedAttachments: ComposerAttachment[];
|
||||||
isComposerLocked: boolean;
|
isComposerLocked: boolean;
|
||||||
@@ -419,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchAgentMessageSendArgs {
|
|
||||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
|
||||||
serverId: string;
|
|
||||||
targetAgentId: string;
|
|
||||||
text: string;
|
|
||||||
sendAttachments: ComposerAttachment[];
|
|
||||||
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
|
|
||||||
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendUserMessageToStream(
|
|
||||||
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
|
|
||||||
): void {
|
|
||||||
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
|
|
||||||
const currentHead = useSessionStore
|
|
||||||
.getState()
|
|
||||||
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
|
|
||||||
if (currentHead && currentHead.length > 0) {
|
|
||||||
setAgentStreamHead(serverId, (prev) => {
|
|
||||||
const head = prev.get(targetAgentId) || [];
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(targetAgentId, [...head, userMessage]);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const currentStream = prev.get(targetAgentId) || [];
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(targetAgentId, [...currentStream, userMessage]);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
|
|
||||||
const { client, targetAgentId, text, sendAttachments } = args;
|
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
|
|
||||||
const clientMessageId = generateMessageId();
|
|
||||||
const userMessage: StreamItem = {
|
|
||||||
kind: "user_message",
|
|
||||||
id: clientMessageId,
|
|
||||||
text,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
|
||||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
|
||||||
};
|
|
||||||
appendUserMessageToStream({ ...args, userMessage });
|
|
||||||
const imagesData = await encodeImages(wirePayload.images);
|
|
||||||
await client.sendAgentMessage(targetAgentId, text, {
|
|
||||||
messageId: clientMessageId,
|
|
||||||
images: imagesData ?? [],
|
|
||||||
attachments: wirePayload.attachments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openComposerAttachment(
|
|
||||||
attachment: ComposerAttachment,
|
|
||||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
|
||||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
|
||||||
): void {
|
|
||||||
if (attachment.kind === "image") {
|
|
||||||
setLightboxMetadata(attachment.metadata);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
|
||||||
openWorkspaceAttachment({ attachment });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void openExternalUrl(attachment.item.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CancelRunningAgentArgs {
|
|
||||||
isAgentRunning: boolean;
|
|
||||||
isCancellingAgent: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
client: ReturnType<typeof useHostRuntimeClient>;
|
|
||||||
agentIdRef: { current: string };
|
|
||||||
setIsCancellingAgent: (value: boolean) => void;
|
|
||||||
messageInputRef: { current: MessageInputRef | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
|
|
||||||
const {
|
|
||||||
isAgentRunning,
|
|
||||||
isCancellingAgent,
|
|
||||||
isConnected,
|
|
||||||
client,
|
|
||||||
agentIdRef,
|
|
||||||
setIsCancellingAgent,
|
|
||||||
messageInputRef,
|
|
||||||
} = args;
|
|
||||||
if (!isAgentRunning || isCancellingAgent) return;
|
|
||||||
if (!isConnected || !client) return;
|
|
||||||
setIsCancellingAgent(true);
|
|
||||||
void client.cancelAgent(agentIdRef.current);
|
|
||||||
messageInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||||
current: MessageInputRef | null;
|
current: MessageInputRef | null;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -1155,14 +993,18 @@ export function Composer({
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
await dispatchAgentMessageSend({
|
const stream: AgentStreamWriter = {
|
||||||
|
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||||
|
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||||
|
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||||
|
};
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
client,
|
client,
|
||||||
serverId,
|
agentId: targetAgentId,
|
||||||
targetAgentId,
|
|
||||||
text,
|
text,
|
||||||
sendAttachments,
|
attachments: sendAttachments,
|
||||||
setAgentStreamHead,
|
encodeImages,
|
||||||
setAgentStreamTail,
|
stream,
|
||||||
});
|
});
|
||||||
onAttentionPromptSend?.();
|
onAttentionPromptSend?.();
|
||||||
};
|
};
|
||||||
@@ -1175,33 +1017,23 @@ export function Composer({
|
|||||||
const isAgentRunning = agentState.status === "running";
|
const isAgentRunning = agentState.status === "running";
|
||||||
const hasAgent = agentState.status !== null;
|
const hasAgent = agentState.status !== null;
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const queueWriter = useMemo<QueueWriter>(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
() => ({
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||||
const next = new Map(prev);
|
write: (updater) => setQueuedMessages(serverId, updater),
|
||||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
}),
|
||||||
return next;
|
[serverId, setQueuedMessages],
|
||||||
});
|
|
||||||
},
|
|
||||||
[agentId, serverId, setQueuedMessages],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const queueMessage = useCallback(
|
const queueMessage = useCallback(
|
||||||
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
||||||
const trimmedMessage = queuedMessage.trim();
|
const result = queueComposerMessage({
|
||||||
if (!trimmedMessage && queuedAttachments.length === 0) return;
|
agentId,
|
||||||
|
text: queuedMessage,
|
||||||
const newItem = {
|
|
||||||
id: generateMessageId(),
|
|
||||||
text: trimmedMessage,
|
|
||||||
attachments: queuedAttachments,
|
attachments: queuedAttachments,
|
||||||
};
|
queue: queueWriter,
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
if (!result.queued) return;
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
@@ -1211,9 +1043,8 @@ export function Composer({
|
|||||||
[
|
[
|
||||||
agentId,
|
agentId,
|
||||||
clearSentAttachments,
|
clearSentAttachments,
|
||||||
|
queueWriter,
|
||||||
resetSuppression,
|
resetSuppression,
|
||||||
serverId,
|
|
||||||
setQueuedMessages,
|
|
||||||
setSelectedAttachments,
|
setSelectedAttachments,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
],
|
],
|
||||||
@@ -1287,7 +1118,15 @@ export function Composer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
const newImages = await pickAndPersistImages(pickImages);
|
const newImages = await pickAndPersistImages({
|
||||||
|
pickImages,
|
||||||
|
persister: {
|
||||||
|
persistFromBlob: ({ blob, mimeType, fileName }) =>
|
||||||
|
persistAttachmentFromBlob({ blob, mimeType, fileName }),
|
||||||
|
persistFromFileUri: ({ uri, mimeType, fileName }) =>
|
||||||
|
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
|
||||||
|
},
|
||||||
|
});
|
||||||
if (newImages.length === 0) return;
|
if (newImages.length === 0) return;
|
||||||
addImages(newImages);
|
addImages(newImages);
|
||||||
}, [addImages, pickImages]);
|
}, [addImages, pickImages]);
|
||||||
@@ -1301,14 +1140,23 @@ export function Composer({
|
|||||||
if (didRemoveWorkspaceAttachment) {
|
if (didRemoveWorkspaceAttachment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
setSelectedAttachments((prev) =>
|
||||||
|
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAttachment = useCallback(
|
const handleOpenAttachment = useCallback(
|
||||||
(attachment: ComposerAttachment) => {
|
(attachment: ComposerAttachment) => {
|
||||||
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
openComposerAttachment({
|
||||||
|
attachment,
|
||||||
|
setLightboxMetadata,
|
||||||
|
openWorkspaceAttachment: openAttachment,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
void openExternalUrl(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[openAttachment],
|
[openAttachment],
|
||||||
);
|
);
|
||||||
@@ -1320,15 +1168,16 @@ export function Composer({
|
|||||||
}, [isAgentRunning, isConnected]);
|
}, [isAgentRunning, isConnected]);
|
||||||
|
|
||||||
const handleCancelAgent = useCallback(() => {
|
const handleCancelAgent = useCallback(() => {
|
||||||
cancelRunningAgent({
|
const didCancel = cancelComposerAgent({
|
||||||
|
client,
|
||||||
|
agentId: agentIdRef.current,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
client,
|
|
||||||
agentIdRef,
|
|
||||||
setIsCancellingAgent,
|
|
||||||
messageInputRef,
|
|
||||||
});
|
});
|
||||||
|
if (!didCancel) return;
|
||||||
|
setIsCancellingAgent(true);
|
||||||
|
messageInputRef.current?.focus();
|
||||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||||
|
|
||||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||||
@@ -1394,33 +1243,34 @@ export function Composer({
|
|||||||
|
|
||||||
const handleEditQueuedMessage = useCallback(
|
const handleEditQueuedMessage = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const result = editQueuedComposerMessage({
|
||||||
if (!item) return;
|
agentId,
|
||||||
|
messageId: id,
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
queue: queueWriter,
|
||||||
setUserInput(item.text);
|
});
|
||||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
if (!result) return;
|
||||||
|
setUserInput(result.text);
|
||||||
|
setSelectedAttachments(result.attachments);
|
||||||
},
|
},
|
||||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendQueuedNow = useCallback(
|
const handleSendQueuedNow = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
|
||||||
if (!item) return;
|
|
||||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||||
|
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
|
||||||
|
|
||||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||||
try {
|
const result = await sendQueuedComposerMessageNow({
|
||||||
await submitMessage(item.text, item.attachments);
|
agentId,
|
||||||
} catch (error) {
|
messageId: id,
|
||||||
updateQueue((current) => [item, ...current]);
|
queue: queueWriter,
|
||||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||||
|
submitMessage(text, queuedAttachments),
|
||||||
|
});
|
||||||
|
if (result.status === "failed") {
|
||||||
|
setSendError(result.errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queuedMessages, submitMessage, updateQueue],
|
[agentId, queueWriter, submitMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQueue = useCallback(
|
const handleQueue = useCallback(
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function TreeRowItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
void onCopyPath(entry.path);
|
onCopyPath(entry.path);
|
||||||
}, [onCopyPath, entry.path]);
|
}, [onCopyPath, entry.path]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "@testing-library/react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
|
||||||
const hoistedPanelState = {
|
|
||||||
isOpen: false,
|
|
||||||
showMobileAgent: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
panelState: hoistedPanelState,
|
|
||||||
useSidebarWorkspacesListMock: vi.fn(),
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#111",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
surface3: "#333",
|
|
||||||
surface4: "#444",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
borderAccent: "#666",
|
|
||||||
accent: "#0a84ff",
|
|
||||||
accentForeground: "#fff",
|
|
||||||
palette: {
|
|
||||||
green: { 400: "#30d158" },
|
|
||||||
amber: { 500: "#ffd60a" },
|
|
||||||
red: { 500: "#ff453a" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
absoluteFillObject: {},
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: "div",
|
|
||||||
},
|
|
||||||
Extrapolation: { CLAMP: "clamp" },
|
|
||||||
interpolate: () => 0,
|
|
||||||
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-gesture-handler", () => {
|
|
||||||
const chain = {
|
|
||||||
enabled: () => chain,
|
|
||||||
hitSlop: () => chain,
|
|
||||||
manualActivation: () => chain,
|
|
||||||
onTouchesDown: () => chain,
|
|
||||||
onTouchesMove: () => chain,
|
|
||||||
onStart: () => chain,
|
|
||||||
onUpdate: () => chain,
|
|
||||||
onEnd: () => chain,
|
|
||||||
onFinalize: () => chain,
|
|
||||||
withRef: () => chain,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
Gesture: { Pan: () => chain },
|
|
||||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
FolderPlus: createIcon("FolderPlus"),
|
|
||||||
MessagesSquare: createIcon("MessagesSquare"),
|
|
||||||
Plus: createIcon("Plus"),
|
|
||||||
Settings: createIcon("Settings"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: { push: vi.fn() },
|
|
||||||
usePathname: () => "/hosts/srv",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/layout", () => ({
|
|
||||||
useIsCompactFormFactor: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/panel-store", () => ({
|
|
||||||
MIN_SIDEBAR_WIDTH: 260,
|
|
||||||
MAX_SIDEBAR_WIDTH: 420,
|
|
||||||
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
|
||||||
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
|
||||||
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
|
||||||
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
|
||||||
useSidebarShortcutModel: () => ({
|
|
||||||
collapsedProjectKeys: new Set<string>(),
|
|
||||||
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
|
||||||
toggleProjectCollapsed: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
|
||||||
useSidebarAnimation: () => ({
|
|
||||||
translateX: { value: 0 },
|
|
||||||
backdropOpacity: { value: 0 },
|
|
||||||
windowWidth: 390,
|
|
||||||
animateToOpen: vi.fn(),
|
|
||||||
animateToClose: vi.fn(),
|
|
||||||
isGesturing: { value: false },
|
|
||||||
gestureAnimatingRef: { current: false },
|
|
||||||
closeGestureRef: { current: undefined },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|
||||||
useShortcutKeys: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/desktop-window", () => ({
|
|
||||||
useWindowControlsPadding: () => ({ top: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/host-routes", () => ({
|
|
||||||
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
|
||||||
buildSettingsRoute: () => "/settings",
|
|
||||||
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
|
||||||
parseServerIdFromPathname: () => "srv",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-open-project-picker", () => ({
|
|
||||||
useOpenProjectPicker: () => vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
|
||||||
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./sidebar-workspace-list", () => ({
|
|
||||||
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
|
||||||
React.createElement(
|
|
||||||
"div",
|
|
||||||
{ "data-testid": "sidebar-workspace-list" },
|
|
||||||
projects.map((project) => project.projectName).join(","),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
|
||||||
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/shortcut", () => ({
|
|
||||||
Shortcut: () => React.createElement("span", null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/combobox", () => ({
|
|
||||||
Combobox: () => null,
|
|
||||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
|
|
||||||
import { LeftSidebar } from "./left-sidebar";
|
|
||||||
|
|
||||||
describe("LeftSidebar", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
panelState.isOpen = false;
|
|
||||||
panelState.showMobileAgent.mockReset();
|
|
||||||
useSidebarWorkspacesListMock.mockReset();
|
|
||||||
useSidebarWorkspacesListMock.mockReturnValue({
|
|
||||||
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
|
||||||
isInitialLoad: false,
|
|
||||||
isRevalidating: false,
|
|
||||||
refreshAll: vi.fn(),
|
|
||||||
});
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<LeftSidebar />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
|
||||||
serverId: "srv",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeCanStartDictation } from "./message-input-state";
|
||||||
|
|
||||||
|
const connected = { isConnected: true } as never;
|
||||||
|
const disconnected = { isConnected: false } as never;
|
||||||
|
|
||||||
|
describe("computeCanStartDictation", () => {
|
||||||
|
it("returns false when socket is disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: disconnected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when isReadyForDictation is explicitly false", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: false,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when connected and ready", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: undefined,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: disconnected,
|
||||||
|
isReadyForDictation: undefined,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the input is disabled", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: true,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a dictation unavailable message is present", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: "Microphone unavailable",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when client is null", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: null,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
|
export function computeCanStartDictation(input: {
|
||||||
|
client: DaemonClient | null;
|
||||||
|
isReadyForDictation: boolean | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
dictationUnavailableMessage: string | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const socketConnected = input.client?.isConnected ?? false;
|
||||||
|
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
||||||
|
return (
|
||||||
|
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import React, { createRef } from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
|
||||||
|
|
||||||
const EMPTY_ATTACHMENTS: React.ComponentProps<typeof MessageInput>["attachments"] = [];
|
|
||||||
const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = [];
|
|
||||||
const FAKE_CONNECTED_CLIENT = { isConnected: true } as never;
|
|
||||||
|
|
||||||
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
|
||||||
startDictationMock: vi.fn(),
|
|
||||||
cancelDictationMock: vi.fn(),
|
|
||||||
confirmDictationMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400" },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
surface3: "#333",
|
|
||||||
surface4: "#888",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
popoverForeground: "#fff",
|
|
||||||
borderAccent: "#444",
|
|
||||||
accent: "#0a84ff",
|
|
||||||
accentForeground: "#fff",
|
|
||||||
destructive: "#ff453a",
|
|
||||||
palette: {
|
|
||||||
green: { 500: "#30d158" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
withUnistyles: <T,>(component: T) => component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: false,
|
|
||||||
isNative: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ArrowUp: createIcon("ArrowUp"),
|
|
||||||
Mic: createIcon("Mic"),
|
|
||||||
MicOff: createIcon("MicOff"),
|
|
||||||
CornerDownLeft: createIcon("CornerDownLeft"),
|
|
||||||
Plus: createIcon("Plus"),
|
|
||||||
Square: createIcon("Square"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: "div",
|
|
||||||
},
|
|
||||||
Keyframe: class Keyframe {
|
|
||||||
duration() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
withCallback() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-dictation", () => ({
|
|
||||||
useDictation: () => ({
|
|
||||||
isRecording: false,
|
|
||||||
isProcessing: false,
|
|
||||||
partialTranscript: "",
|
|
||||||
volume: 0,
|
|
||||||
duration: 0,
|
|
||||||
error: null,
|
|
||||||
status: "idle",
|
|
||||||
startDictation: startDictationMock,
|
|
||||||
cancelDictation: cancelDictationMock,
|
|
||||||
confirmDictation: confirmDictationMock,
|
|
||||||
retryFailedDictation: vi.fn(),
|
|
||||||
discardFailedDictation: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
|
|
||||||
selector({ sessions: {} }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/voice-context", () => ({
|
|
||||||
useVoiceOptional: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/toast-context", () => ({
|
|
||||||
useToast: () => ({ error: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
|
||||||
resolveVoiceUnavailableMessage: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/use-web-scrollbar", () => ({
|
|
||||||
useWebElementScrollbar: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"@/hooks/use-web-scrollbar-style",
|
|
||||||
() => ({
|
|
||||||
useWebScrollbarStyle: () => undefined,
|
|
||||||
}),
|
|
||||||
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
|
|
||||||
{ virtual: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|
||||||
useShortcutKeys: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/shortcut", () => ({
|
|
||||||
Shortcut: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipTrigger: ({
|
|
||||||
asChild,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
asChild?: boolean;
|
|
||||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
|
||||||
} & Record<string, unknown>) =>
|
|
||||||
asChild ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<button type="button" aria-label={props.accessibilityLabel as string | undefined}>
|
|
||||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
DropdownMenuTrigger: ({
|
|
||||||
children,
|
|
||||||
testID,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
children:
|
|
||||||
| React.ReactNode
|
|
||||||
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
|
||||||
testID?: string;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
|
|
||||||
{typeof children === "function"
|
|
||||||
? children({ hovered: false, pressed: false, open: false })
|
|
||||||
: children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
|
||||||
<div data-testid={testID}>{children}</div>
|
|
||||||
),
|
|
||||||
DropdownMenuItem: ({
|
|
||||||
children,
|
|
||||||
onSelect,
|
|
||||||
testID,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onSelect?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./dictation-controls", () => ({
|
|
||||||
DictationOverlay: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./realtime-voice-overlay", () => ({
|
|
||||||
RealtimeVoiceOverlay: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
startDictationMock.mockReset();
|
|
||||||
cancelDictationMock.mockReset();
|
|
||||||
confirmDictationMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
interface RenderMessageInputOptions {
|
|
||||||
value?: string;
|
|
||||||
submitIcon?: "arrow" | "return";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMessageInput(
|
|
||||||
menuItems: AttachmentMenuItem[],
|
|
||||||
{ value = "", submitIcon }: RenderMessageInputOptions = {},
|
|
||||||
) {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={menuItems}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
submitIcon={submitIcon}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(element: Element) {
|
|
||||||
act(() => {
|
|
||||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryByTestId(testID: string): HTMLElement | null {
|
|
||||||
return document.querySelector(`[data-testid="${testID}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
|
|
||||||
return document.querySelectorAll(`[aria-label="${label}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MessageInput attachments", () => {
|
|
||||||
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
|
|
||||||
const menuItems: AttachmentMenuItem[] = [
|
|
||||||
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
|
||||||
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
|
||||||
];
|
|
||||||
|
|
||||||
renderMessageInput(menuItems);
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
|
|
||||||
|
|
||||||
const attachButton = queryByTestId("message-input-attach-button");
|
|
||||||
expect(attachButton).not.toBeNull();
|
|
||||||
click(attachButton!);
|
|
||||||
|
|
||||||
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
|
|
||||||
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
|
|
||||||
).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selecting Attach image invokes the supplied image attachment action", () => {
|
|
||||||
const attachImage = vi.fn();
|
|
||||||
|
|
||||||
renderMessageInput([
|
|
||||||
{ id: "image", label: "Attach image", onSelect: attachImage },
|
|
||||||
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
click(queryByTestId("message-input-attach-button")!);
|
|
||||||
click(queryByTestId("message-input-attachment-menu-item-image")!);
|
|
||||||
|
|
||||||
expect(attachImage).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render the old queue button", () => {
|
|
||||||
renderMessageInput([
|
|
||||||
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
|
||||||
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
|
|
||||||
renderMessageInput([], { value: "Send this" });
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
|
|
||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
|
|
||||||
|
|
||||||
renderMessageInput([], { value: "Create this", submitIcon: "return" });
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
|
|
||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("MessageInput dictation shortcuts", () => {
|
|
||||||
it("does not poison the dictation toggle when readiness is temporarily false", () => {
|
|
||||||
const inputRef = createRef<MessageInputRef>();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation={false}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
expect(startDictationMock).not.toHaveBeenCalled();
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startDictationMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -53,6 +53,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
|||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
import { computeCanStartDictation } from "./message-input-state";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -968,19 +969,6 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut
|
|||||||
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
|
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCanStartDictation(input: {
|
|
||||||
client: DaemonClient | null;
|
|
||||||
isReadyForDictation: boolean | undefined;
|
|
||||||
disabled: boolean;
|
|
||||||
dictationUnavailableMessage: string | null | undefined;
|
|
||||||
}): boolean {
|
|
||||||
const socketConnected = input.client?.isConnected ?? false;
|
|
||||||
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
|
||||||
return (
|
|
||||||
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeIsDictationStartEnabled(
|
function computeIsDictationStartEnabled(
|
||||||
isReadyForDictation: boolean | undefined,
|
isReadyForDictation: boolean | undefined,
|
||||||
isConnected: boolean,
|
isConnected: boolean,
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ interface MarkdownWithStableRendererProps {
|
|||||||
|
|
||||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||||
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
|
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||||
|
style: createMarkdownStyles(theme),
|
||||||
|
});
|
||||||
|
|
||||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||||
@@ -428,6 +430,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
|||||||
return `Issue #${attachment.number}`;
|
return `Issue #${attachment.number}`;
|
||||||
case "text":
|
case "text":
|
||||||
return attachment.title ?? "Text attachment";
|
return attachment.title ?? "Text attachment";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +638,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedMetadata) {
|
if (cachedMetadata) {
|
||||||
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadState({ status: "loading" });
|
setLoadState({ status: "loading" });
|
||||||
@@ -924,11 +928,11 @@ function getInlineCodeAutoLinkUrl(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = markdownParser.linkify.match(trimmed) as Array<{
|
const matches: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
lastIndex: number;
|
lastIndex: number;
|
||||||
url: string;
|
url: string;
|
||||||
}> | null;
|
}> | null = markdownParser.linkify.match(trimmed);
|
||||||
if (!matches || matches.length !== 1) {
|
if (!matches || matches.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -950,7 +954,7 @@ function nodeHasParentType(parent: unknown, type: string): boolean {
|
|||||||
typeof parent === "object" &&
|
typeof parent === "object" &&
|
||||||
parent !== null &&
|
parent !== null &&
|
||||||
"type" in parent &&
|
"type" in parent &&
|
||||||
(parent as { type?: string }).type === type
|
(parent as Record<"type", unknown>)["type"] === type
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1638,16 +1642,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
style={styles.link}
|
style={styles.link}
|
||||||
onPress={handleLinkPress}
|
onPress={handleLinkPress}
|
||||||
>
|
>
|
||||||
{Children.map(children, (child) =>
|
{Children.map(children, (child) => {
|
||||||
isValidElement(child)
|
if (!isValidElement(child)) return child;
|
||||||
? cloneElement(child, {
|
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||||||
style: [
|
return cloneElement(child, {
|
||||||
(child.props as { style?: StyleProp<TextStyle> }).style,
|
style: [childProps.style, { color: styles.link.color }],
|
||||||
{ color: styles.link.color as string | undefined },
|
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||||
],
|
})}
|
||||||
} as Partial<{ style: StyleProp<TextStyle> }>)
|
|
||||||
: child,
|
|
||||||
)}
|
|
||||||
</MarkdownLink>
|
</MarkdownLink>
|
||||||
),
|
),
|
||||||
image: (
|
image: (
|
||||||
@@ -1740,8 +1741,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
headerLabel: {
|
headerLabel: {
|
||||||
fontFamily: Fonts.sans,
|
fontFamily: Fonts.sans,
|
||||||
fontSize: 12,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: "500",
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
@@ -1769,7 +1770,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
|||||||
return (
|
return (
|
||||||
<View testID="speak-message" style={containerStyle}>
|
<View testID="speak-message" style={containerStyle}>
|
||||||
<View style={speakMessageStylesheet.header}>
|
<View style={speakMessageStylesheet.header}>
|
||||||
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||||
@@ -1810,9 +1811,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
|||||||
successBg: {
|
successBg: {
|
||||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||||
},
|
},
|
||||||
errorBg: {
|
errorBg: {},
|
||||||
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
|
||||||
},
|
|
||||||
artifactBg: {
|
artifactBg: {
|
||||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||||
},
|
},
|
||||||
@@ -1827,6 +1826,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
height: 20,
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1936,7 +1937,9 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
<IconComponent size={16} color={config.color} />
|
<IconComponent size={16} color={config.color} />
|
||||||
</View>
|
</View>
|
||||||
<View style={activityLogStylesheet.textContainer}>
|
<View style={activityLogStylesheet.textContainer}>
|
||||||
<Text style={messageTextStyle}>{displayMessage}</Text>
|
<Text style={messageTextStyle} selectable>
|
||||||
|
{displayMessage}
|
||||||
|
</Text>
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<View style={activityLogStylesheet.detailsRow}>
|
<View style={activityLogStylesheet.detailsRow}>
|
||||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||||
@@ -2419,12 +2422,13 @@ function useDetailWheelPropagationBlocker(input: {
|
|||||||
const { detailWrapperRef, enabled } = input;
|
const { detailWrapperRef, enabled } = input;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
const rawRef: unknown = detailWrapperRef.current;
|
||||||
if (!node || typeof node.addEventListener !== "function") {
|
if (!(rawRef instanceof HTMLElement)) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
const node = rawRef;
|
||||||
const stopWheelPropagation = (event: WheelEvent) => {
|
const stopWheelPropagation = (event: WheelEvent) => {
|
||||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -2594,7 +2598,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
enabled: !isNative && isExpanded && hasDetailContent,
|
enabled: !isNative && isExpanded && hasDetailContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shimmerLabelStyle = useMemo(
|
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() =>
|
() =>
|
||||||
buildShimmerTextStyle({
|
buildShimmerTextStyle({
|
||||||
isWebShimmer,
|
isWebShimmer,
|
||||||
@@ -2603,7 +2607,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
webShimmerTrackStart,
|
webShimmerTrackStart,
|
||||||
webShimmerTrackEnd,
|
webShimmerTrackEnd,
|
||||||
offsetX: labelOffsetX,
|
offsetX: labelOffsetX,
|
||||||
}) as never,
|
}),
|
||||||
[
|
[
|
||||||
isWebShimmer,
|
isWebShimmer,
|
||||||
webShimmerPeakWidth,
|
webShimmerPeakWidth,
|
||||||
@@ -2614,7 +2618,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shimmerSecondaryStyle = useMemo(
|
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() =>
|
() =>
|
||||||
buildShimmerTextStyle({
|
buildShimmerTextStyle({
|
||||||
isWebShimmer,
|
isWebShimmer,
|
||||||
@@ -2623,7 +2627,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
webShimmerTrackStart,
|
webShimmerTrackStart,
|
||||||
webShimmerTrackEnd,
|
webShimmerTrackEnd,
|
||||||
offsetX: secondaryOffsetX,
|
offsetX: secondaryOffsetX,
|
||||||
}) as never,
|
}),
|
||||||
[
|
[
|
||||||
isWebShimmer,
|
isWebShimmer,
|
||||||
webShimmerPeakWidth,
|
webShimmerPeakWidth,
|
||||||
@@ -2805,9 +2809,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
|||||||
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args?: unknown | null;
|
args?: unknown;
|
||||||
result?: unknown | null;
|
result?: unknown;
|
||||||
error?: unknown | null;
|
error?: unknown;
|
||||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||||
detail?: ToolCallDetail;
|
detail?: ToolCallDetail;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -2946,7 +2950,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsExpandedChange) {
|
if (!onInlineDetailsExpandedChange) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
onInlineDetailsExpandedChange(false);
|
onInlineDetailsExpandedChange(false);
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { PrPaneData } from "@/utils/pr-pane-data";
|
|
||||||
import { PrPane } from "./pr-pane";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400" },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#0b0b0b",
|
|
||||||
surfaceSidebarHover: "#151515",
|
|
||||||
border: "#222",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
statusSuccess: "#30d158",
|
|
||||||
statusDanger: "#ff453a",
|
|
||||||
statusWarning: "#f2c945",
|
|
||||||
statusMerged: "#a371f7",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ChevronDown: createIcon("ChevronDown"),
|
|
||||||
ChevronRight: createIcon("ChevronRight"),
|
|
||||||
CircleCheck: createIcon("CircleCheck"),
|
|
||||||
CircleDot: createIcon("CircleDot"),
|
|
||||||
CircleSlash: createIcon("CircleSlash"),
|
|
||||||
CircleX: createIcon("CircleX"),
|
|
||||||
ExternalLink: createIcon("ExternalLink"),
|
|
||||||
GitMerge: createIcon("GitMerge"),
|
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
|
||||||
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
|
|
||||||
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
|
|
||||||
MessageSquare: createIcon("MessageSquare"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
|
||||||
return {
|
|
||||||
number: 1284,
|
|
||||||
title: "fix(server,app): coalesce tool call stream events",
|
|
||||||
state: "open",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
|
||||||
reviewDecision: "changes_requested",
|
|
||||||
awaitingReviewers: [],
|
|
||||||
checks: [
|
|
||||||
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
|
|
||||||
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
|
|
||||||
],
|
|
||||||
activity: [
|
|
||||||
{
|
|
||||||
kind: "review",
|
|
||||||
author: "alicek",
|
|
||||||
avatarColor: "#8b5cf6",
|
|
||||||
reviewState: "approved",
|
|
||||||
body: "LGTM",
|
|
||||||
age: "2h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "comment",
|
|
||||||
author: "bmartin",
|
|
||||||
avatarColor: "#f97316",
|
|
||||||
body: "Worth a benchmark before/after.",
|
|
||||||
age: "3h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(data: PrPaneData) {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<PrPane data={data} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PrPane", () => {
|
|
||||||
it("renders the PR title and 'Open' state label from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
|
|
||||||
expect(container?.textContent).toContain("Open");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the correct state label and icon for each PR state", () => {
|
|
||||||
render(makeData({ state: "draft" }));
|
|
||||||
expect(container?.textContent).toContain("Draft");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "merged" }));
|
|
||||||
expect(container?.textContent).toContain("Merged");
|
|
||||||
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "closed" }));
|
|
||||||
expect(container?.textContent).toContain("Closed");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives check summary counts from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("typecheck");
|
|
||||||
expect(text).toContain("test");
|
|
||||||
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
|
|
||||||
expect(text).toMatch(/1.*1/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives review summary counts and renders activity rows", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("alicek");
|
|
||||||
expect(text).toContain("Approved");
|
|
||||||
expect(text).toContain("bmartin");
|
|
||||||
expect(text).toContain("Commented");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||||
import type {
|
import type {
|
||||||
CheckStatus,
|
CheckStatus,
|
||||||
PrPaneActivity,
|
PrPaneActivity,
|
||||||
@@ -283,13 +284,6 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityVerb(item: PrPaneActivity): string {
|
|
||||||
if (item.kind === "comment") return "Commented";
|
|
||||||
if (item.reviewState === "approved") return "Approved";
|
|
||||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
|
||||||
return "Reviewed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||||
if (state === "open") return theme.colors.statusSuccess;
|
if (state === "open") return theme.colors.statusSuccess;
|
||||||
if (state === "draft") return theme.colors.foregroundMuted;
|
if (state === "draft") return theme.colors.foregroundMuted;
|
||||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
|||||||
return GitPullRequest;
|
return GitPullRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStateLabel(state: PrState): string {
|
|
||||||
if (state === "draft") return "Draft";
|
|
||||||
if (state === "merged") return "Merged";
|
|
||||||
if (state === "closed") return "Closed";
|
|
||||||
return "Open";
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface PathRowProps {
|
|||||||
function PathRow({ path, active, onSelect }: PathRowProps) {
|
function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
void onSelect(path);
|
onSelect(path);
|
||||||
}, [onSelect, path]);
|
}, [onSelect, path]);
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
@@ -172,7 +172,7 @@ export function ProjectPickerModal() {
|
|||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (options.length > 0 && activeIndex < options.length) {
|
if (options.length > 0 && activeIndex < options.length) {
|
||||||
void handleSelectPath(options[activeIndex]!);
|
void handleSelectPath(options[activeIndex]);
|
||||||
} else if (query.trim()) {
|
} else if (query.trim()) {
|
||||||
handleSubmitCustom();
|
handleSubmitCustom();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function CustomModelsSection(props: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await refresh([provider as AgentProvider]);
|
await refresh([provider]);
|
||||||
},
|
},
|
||||||
[patchConfig, provider, refresh],
|
[patchConfig, provider, refresh],
|
||||||
);
|
);
|
||||||
@@ -284,7 +284,7 @@ export function ProviderDiagnosticSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
const result = await client.getProviderDiagnostic(provider);
|
||||||
setDiagnostic(result.diagnostic);
|
setDiagnostic(result.diagnostic);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||||
@@ -308,7 +308,7 @@ export function ProviderDiagnosticSheet({
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
void Promise.all([refresh([provider]), fetchDiagnostic()]).catch((err) => {
|
||||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||||
});
|
});
|
||||||
}, [fetchDiagnostic, provider, refresh]);
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||||
{
|
{
|
||||||
...createWorkspaces()[1]!,
|
...createWorkspaces()[1],
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ function WorkspaceStatusIndicator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
|
let KindIcon: typeof ThemedMonitor | null;
|
||||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||||
else KindIcon = null;
|
else KindIcon = null;
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ interface SplitPaneDropData {
|
|||||||
paneId: string;
|
paneId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||||
|
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||||
|
return (
|
||||||
|
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||||
|
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||||
|
if (isWorkspaceTabDragData(data)) return data;
|
||||||
|
if (isSplitPaneDropData(data)) return data;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
@@ -185,10 +205,10 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
[buildPaneContentModel, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapperStyle = useMemo(
|
const wrapperStyle = useMemo(() => {
|
||||||
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
|
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||||
[isVisible],
|
return { display, flex: 1 };
|
||||||
);
|
}, [isVisible]);
|
||||||
const handleFocusPane = useCallback(() => {
|
const handleFocusPane = useCallback(() => {
|
||||||
onFocusPane(paneId);
|
onFocusPane(paneId);
|
||||||
}, [onFocusPane, paneId]);
|
}, [onFocusPane, paneId]);
|
||||||
@@ -388,8 +408,8 @@ export function SplitContainer({
|
|||||||
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||||
if (data?.kind !== "workspace-tab") {
|
if (!data) {
|
||||||
setActiveDragTabId(null);
|
setActiveDragTabId(null);
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
setTabDropPreview(null);
|
setTabDropPreview(null);
|
||||||
@@ -406,11 +426,8 @@ export function SplitContainer({
|
|||||||
|
|
||||||
const updateDropPreview = useCallback(
|
const updateDropPreview = useCallback(
|
||||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||||
const overData = event.over?.data.current as
|
const overData = asDragOverData(event.over?.data.current);
|
||||||
| WorkspaceTabDragData
|
|
||||||
| SplitPaneDropData
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (activeData?.kind !== "workspace-tab") {
|
if (activeData?.kind !== "workspace-tab") {
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
@@ -513,11 +530,8 @@ export function SplitContainer({
|
|||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||||
const overData = event.over?.data.current as
|
const overData = asDragOverData(event.over?.data.current);
|
||||||
| WorkspaceTabDragData
|
|
||||||
| SplitPaneDropData
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
setActiveDragTabId(null);
|
setActiveDragTabId(null);
|
||||||
|
|
||||||
@@ -905,17 +919,14 @@ function SplitPaneView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
const rawRef: unknown = paneRef.current;
|
||||||
if (
|
if (!(rawRef instanceof HTMLElement)) {
|
||||||
!paneElement ||
|
return () => {};
|
||||||
typeof paneElement.addEventListener !== "function" ||
|
|
||||||
typeof paneElement.removeEventListener !== "function"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const paneElement = rawRef;
|
||||||
|
|
||||||
const handlePanePointerDown = (event: PointerEvent) => {
|
const handlePanePointerDown = (event: PointerEvent) => {
|
||||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -142,6 +143,16 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalState(value: unknown): value is TerminalState {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"rows" in value &&
|
||||||
|
"cols" in value &&
|
||||||
|
"grid" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTerminalScrollbarStyle(): void {
|
function ensureTerminalScrollbarStyle(): void {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -229,20 +240,41 @@ export default function TerminalEmulator({
|
|||||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||||
|
|
||||||
|
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||||
useDOMImperativeHandle(
|
useDOMImperativeHandle(
|
||||||
ref as Ref<DOMImperativeFactory>,
|
domBridgeRef,
|
||||||
() =>
|
(): DOMImperativeFactory => ({
|
||||||
({
|
writeOutput: (...args) => {
|
||||||
writeOutput: (text: string) => {
|
const text = args[0];
|
||||||
runtimeRef.current?.write({ text });
|
if (typeof text === "string") runtimeRef.current?.write({ text });
|
||||||
},
|
},
|
||||||
renderSnapshot: (state: TerminalState | null) => {
|
renderSnapshot: (...args) => {
|
||||||
|
const state = args[0];
|
||||||
|
if (state === null) {
|
||||||
|
runtimeRef.current?.renderSnapshot({ state: null });
|
||||||
|
} else if (isTerminalState(state)) {
|
||||||
runtimeRef.current?.renderSnapshot({ state });
|
runtimeRef.current?.renderSnapshot({ state });
|
||||||
},
|
}
|
||||||
clear: () => {
|
},
|
||||||
runtimeRef.current?.clear();
|
clear: () => {
|
||||||
},
|
runtimeRef.current?.clear();
|
||||||
}) as unknown as DOMImperativeFactory,
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
(): TerminalEmulatorHandle => ({
|
||||||
|
writeOutput: (text: string) => {
|
||||||
|
runtimeRef.current?.write({ text });
|
||||||
|
},
|
||||||
|
renderSnapshot: (state: TerminalState | null) => {
|
||||||
|
runtimeRef.current?.renderSnapshot({ state });
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
runtimeRef.current?.clear();
|
||||||
|
},
|
||||||
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,7 +291,7 @@ export default function TerminalEmulator({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root || !swipeGesturesEnabled) {
|
if (!root || !swipeGesturesEnabled) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SWIPE_MIN_PX = 22;
|
const SWIPE_MIN_PX = 22;
|
||||||
@@ -376,7 +408,7 @@ export default function TerminalEmulator({
|
|||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!host || !root) {
|
if (!host || !root) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime = new TerminalEmulatorRuntime();
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
@@ -423,7 +455,7 @@ export default function TerminalEmulator({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
runtimeRef.current?.resize({ force: true });
|
runtimeRef.current?.resize({ force: true });
|
||||||
return focusWithRetries({
|
return focusWithRetries({
|
||||||
@@ -451,14 +483,14 @@ export default function TerminalEmulator({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||||
if (!viewportElement) {
|
if (!viewportElement) {
|
||||||
viewportRef.current = null;
|
viewportRef.current = null;
|
||||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
viewportRef.current = viewportElement;
|
viewportRef.current = viewportElement;
|
||||||
@@ -566,7 +598,7 @@ export default function TerminalEmulator({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDraggingScrollbar) {
|
if (!isDraggingScrollbar) {
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
|||||||
@@ -228,8 +228,7 @@ export function ComboboxItem({
|
|||||||
const itemPressableStyle = useCallback(
|
const itemPressableStyle = useCallback(
|
||||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.comboboxItem,
|
styles.comboboxItem,
|
||||||
Boolean(hovered) &&
|
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
|
||||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
disabled && styles.comboboxItemDisabled,
|
disabled && styles.comboboxItemDisabled,
|
||||||
@@ -488,7 +487,7 @@ function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
|
|||||||
if (input.orderedVisibleOptions.length === 0) return;
|
if (input.orderedVisibleOptions.length === 0) return;
|
||||||
const { activeIndex, orderedVisibleOptions } = input;
|
const { activeIndex, orderedVisibleOptions } = input;
|
||||||
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||||
input.handleSelect(orderedVisibleOptions[index]!.id);
|
input.handleSelect(orderedVisibleOptions[index].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FloatingSizeSetters {
|
interface FloatingSizeSetters {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Ref,
|
type Ref,
|
||||||
type MutableRefObject,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -89,7 +88,7 @@ function useControllableOpenState({
|
|||||||
}): [boolean, (next: boolean) => void] {
|
}): [boolean, (next: boolean) => void] {
|
||||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const value = isControlled ? Boolean(open) : internalOpen;
|
const value = isControlled ? open : internalOpen;
|
||||||
const setValue = useCallback(
|
const setValue = useCallback(
|
||||||
(next: boolean) => {
|
(next: boolean) => {
|
||||||
if (!isControlled) setInternalOpen(next);
|
if (!isControlled) setInternalOpen(next);
|
||||||
@@ -175,25 +174,23 @@ function computePosition({
|
|||||||
return { x, y, actualPlacement };
|
return { x, y, actualPlacement };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||||
|
return typeof fn === "function";
|
||||||
|
}
|
||||||
|
|
||||||
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
||||||
const wrapper = event as
|
if (typeof event !== "object" || event === null) {
|
||||||
| {
|
return null;
|
||||||
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
|
}
|
||||||
pageX?: number;
|
const nativeEvent = Reflect.get(event, "nativeEvent");
|
||||||
pageY?: number;
|
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||||
clientX?: number;
|
const pageX = Reflect.get(native, "pageX");
|
||||||
clientY?: number;
|
const pageY = Reflect.get(native, "pageY");
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
|
|
||||||
const pageX = nativeEvent?.pageX;
|
|
||||||
const pageY = nativeEvent?.pageY;
|
|
||||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||||
return { pageX, pageY };
|
return { pageX, pageY };
|
||||||
}
|
}
|
||||||
const clientX = nativeEvent?.clientX;
|
const clientX = Reflect.get(native, "clientX");
|
||||||
const clientY = nativeEvent?.clientY;
|
const clientY = Reflect.get(native, "clientY");
|
||||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||||
return { pageX: clientX, pageY: clientY };
|
return { pageX: clientX, pageY: clientY };
|
||||||
}
|
}
|
||||||
@@ -206,7 +203,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ref && typeof ref === "object") {
|
if (ref && typeof ref === "object") {
|
||||||
(ref as MutableRefObject<T>).current = value;
|
Object.assign(ref, { current: value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,10 +323,13 @@ export function ContextMenuTrigger({
|
|||||||
if (isNative) {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
|
if (typeof event === "object" && event !== null) {
|
||||||
e?.preventDefault?.();
|
const preventDefault = Reflect.get(event, "preventDefault");
|
||||||
e?.stopPropagation?.();
|
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||||
openAtEvent(event as GestureResponderEvent);
|
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||||
|
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||||
|
}
|
||||||
|
openAtEvent(event);
|
||||||
},
|
},
|
||||||
[openAtEvent],
|
[openAtEvent],
|
||||||
);
|
);
|
||||||
@@ -337,7 +337,7 @@ export function ContextMenuTrigger({
|
|||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
if (typeof style === "function") {
|
if (typeof style === "function") {
|
||||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
return style({ pressed, hovered, open: ctx.open });
|
||||||
}
|
}
|
||||||
return style;
|
return style;
|
||||||
},
|
},
|
||||||
@@ -434,40 +434,29 @@ export function ContextMenuContent({
|
|||||||
|
|
||||||
// Measure trigger when opening (fallback) and capture point anchors.
|
// Measure trigger when opening (fallback) and capture point anchors.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useMobileSheet) {
|
if (useMobileSheet || !open) {
|
||||||
setTriggerRect(null);
|
setTriggerRect(null);
|
||||||
setContentSize(null);
|
setContentSize(null);
|
||||||
setPosition(null);
|
setPosition(null);
|
||||||
return;
|
return () => {};
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setTriggerRect(null);
|
|
||||||
setContentSize(null);
|
|
||||||
setPosition(null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anchorRect) {
|
if (anchorRect) {
|
||||||
setTriggerRect(anchorRect);
|
setTriggerRect(anchorRect);
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!triggerRef.current) {
|
if (!triggerRef.current) {
|
||||||
setTriggerRect(null);
|
setTriggerRect(null);
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
measureElement(triggerRef.current).then((rect) => {
|
void measureElement(triggerRef.current).then((rect) => {
|
||||||
if (cancelled) return;
|
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||||
setTriggerRect({
|
return undefined;
|
||||||
...rect,
|
|
||||||
y: rect.y + statusBarHeight,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function useControllableOpenState({
|
|||||||
}): [boolean, (next: boolean) => void] {
|
}): [boolean, (next: boolean) => void] {
|
||||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const value = isControlled ? Boolean(open) : internalOpen;
|
const value = isControlled ? open : internalOpen;
|
||||||
const setValue = useCallback(
|
const setValue = useCallback(
|
||||||
(next: boolean) => {
|
(next: boolean) => {
|
||||||
if (!isControlled) setInternalOpen(next);
|
if (!isControlled) setInternalOpen(next);
|
||||||
@@ -748,12 +748,12 @@ export function DropdownMenuItem({
|
|||||||
return [
|
return [
|
||||||
styles.item,
|
styles.item,
|
||||||
selectedStyle,
|
selectedStyle,
|
||||||
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
|
selected && (hovered || pressed) && selectedVariant !== "accent"
|
||||||
? styles.itemSelectedInteractive
|
? styles.itemSelectedInteractive
|
||||||
: null,
|
: null,
|
||||||
isDisabled ? styles.itemDisabled : null,
|
isDisabled ? styles.itemDisabled : null,
|
||||||
muted && !isDisabled ? styles.itemMuted : null,
|
muted && !isDisabled ? styles.itemMuted : null,
|
||||||
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
|
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||||
pressed && !isDisabled ? styles.itemPressed : null,
|
pressed && !isDisabled ? styles.itemPressed : null,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function Switch({
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
accessibilityRole="switch"
|
accessibilityRole="switch"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
|
aria-checked={value}
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type Ref,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -82,24 +81,20 @@ function shouldOpenOnFocus(): boolean {
|
|||||||
return !isWeb || lastInputWasKeyboard;
|
return !isWeb || lastInputWasKeyboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeEventHandlers<E>(
|
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||||
original?: (event: E) => void,
|
return typeof fn === "function";
|
||||||
injected?: (event: E) => void,
|
|
||||||
): (event: E) => void {
|
|
||||||
return (event: E) => {
|
|
||||||
original?.(event);
|
|
||||||
injected?.(event);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
function composeEventHandlers(
|
||||||
if (typeof ref === "function") {
|
original: unknown,
|
||||||
ref(value);
|
injected: (event: unknown) => void,
|
||||||
return;
|
): (event: unknown) => void {
|
||||||
}
|
return (event: unknown) => {
|
||||||
if (ref && typeof ref === "object") {
|
if (isCallable(original)) {
|
||||||
(ref as { current: T }).current = value;
|
original(event);
|
||||||
}
|
}
|
||||||
|
injected(event);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useControllableOpenState({
|
function useControllableOpenState({
|
||||||
@@ -113,7 +108,7 @@ function useControllableOpenState({
|
|||||||
}): [boolean, (next: boolean) => void] {
|
}): [boolean, (next: boolean) => void] {
|
||||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const value = isControlled ? Boolean(open) : internalOpen;
|
const value = isControlled ? open : internalOpen;
|
||||||
const setValue = useCallback(
|
const setValue = useCallback(
|
||||||
(next: boolean) => {
|
(next: boolean) => {
|
||||||
if (!isControlled) setInternalOpen(next);
|
if (!isControlled) setInternalOpen(next);
|
||||||
@@ -320,7 +315,7 @@ export function TooltipTrigger({
|
|||||||
|
|
||||||
const handleHoverIn = useCallback(
|
const handleHoverIn = useCallback(
|
||||||
(e?: unknown) => {
|
(e?: unknown) => {
|
||||||
onHoverIn?.(e as never);
|
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||||
scheduleOpen();
|
scheduleOpen();
|
||||||
},
|
},
|
||||||
[onHoverIn, scheduleOpen],
|
[onHoverIn, scheduleOpen],
|
||||||
@@ -328,7 +323,7 @@ export function TooltipTrigger({
|
|||||||
|
|
||||||
const handleHoverOut = useCallback(
|
const handleHoverOut = useCallback(
|
||||||
(e?: unknown) => {
|
(e?: unknown) => {
|
||||||
onHoverOut?.(e as never);
|
if (isCallable(onHoverOut)) onHoverOut(e);
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
[onHoverOut, close],
|
[onHoverOut, close],
|
||||||
@@ -336,7 +331,7 @@ export function TooltipTrigger({
|
|||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
onFocus?.(e as never);
|
if (isCallable(onFocus)) onFocus(e);
|
||||||
if (!ctx.enabled || disabled) return;
|
if (!ctx.enabled || disabled) return;
|
||||||
if (!shouldOpenOnFocus()) return;
|
if (!shouldOpenOnFocus()) return;
|
||||||
clearOpenTimer();
|
clearOpenTimer();
|
||||||
@@ -347,7 +342,7 @@ export function TooltipTrigger({
|
|||||||
|
|
||||||
const handleBlur = useCallback(
|
const handleBlur = useCallback(
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
onBlur?.(e as never);
|
if (isCallable(onBlur)) onBlur(e);
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
[close, onBlur],
|
[close, onBlur],
|
||||||
@@ -355,7 +350,7 @@ export function TooltipTrigger({
|
|||||||
|
|
||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
onPress?.(e as never);
|
if (isCallable(onPress)) onPress(e);
|
||||||
if (!ctx.enabled || disabled) {
|
if (!ctx.enabled || disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,26 +389,33 @@ export function TooltipTrigger({
|
|||||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||||
}
|
}
|
||||||
|
|
||||||
const childProps = child.props as Record<string, unknown>;
|
const rawProps: unknown = child.props;
|
||||||
const mergedProps = {
|
if (typeof rawProps !== "object" || rawProps === null) {
|
||||||
...childProps,
|
throw new Error("TooltipTrigger asChild child must have props object");
|
||||||
|
}
|
||||||
|
const mergedProps: Record<string, unknown> = {
|
||||||
|
...Object.assign({}, rawProps),
|
||||||
...triggerProps,
|
...triggerProps,
|
||||||
disabled: childProps.disabled || disabled,
|
disabled: Reflect.get(rawProps, "disabled") || disabled,
|
||||||
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
|
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
|
||||||
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
|
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
|
||||||
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
|
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
|
||||||
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
|
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
|
||||||
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
|
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
|
||||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
|
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
|
||||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
|
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
|
||||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
|
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
|
||||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
|
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
|
||||||
} as Record<string, unknown>;
|
};
|
||||||
|
|
||||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
|
||||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
mergedProps[triggerRefProp] = (node: View) => {
|
||||||
assignRef(existingRefProp, node);
|
if (isCallable(existingRefProp)) {
|
||||||
assignRef(ctx.triggerRef, node);
|
existingRefProp(node);
|
||||||
|
} else if (existingRefProp && typeof existingRefProp === "object") {
|
||||||
|
Object.assign(existingRefProp, { current: node });
|
||||||
|
}
|
||||||
|
Object.assign(ctx.triggerRef, { current: node });
|
||||||
};
|
};
|
||||||
|
|
||||||
return cloneElement(child, mergedProps);
|
return cloneElement(child, mergedProps);
|
||||||
@@ -453,16 +455,15 @@ export function TooltipContent({
|
|||||||
setTriggerRect(null);
|
setTriggerRect(null);
|
||||||
setContentSize(null);
|
setContentSize(null);
|
||||||
setPosition(null);
|
setPosition(null);
|
||||||
return;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
void measureElement(ctx.triggerRef.current).then((rect) => {
|
||||||
if (cancelled) return;
|
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
return undefined;
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -140,9 +140,9 @@ export function useWebScrollViewScrollbar(
|
|||||||
const scrollable = scrollableRef.current;
|
const scrollable = scrollableRef.current;
|
||||||
if (!scrollable) return;
|
if (!scrollable) return;
|
||||||
if ("scrollToOffset" in scrollable) {
|
if ("scrollToOffset" in scrollable) {
|
||||||
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
scrollable.scrollToOffset({ offset, animated: false });
|
||||||
} else {
|
} else {
|
||||||
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
scrollable.scrollTo({ y: offset, animated: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[scrollableRef],
|
[scrollableRef],
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import {
|
|||||||
type TimelineReducerSideEffect,
|
type TimelineReducerSideEffect,
|
||||||
} from "@/timeline/session-stream-reducers";
|
} from "@/timeline/session-stream-reducers";
|
||||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||||
import type {
|
import type { AgentAttachment, SessionOutboundMessage } from "@server/shared/messages";
|
||||||
AgentAttachment,
|
|
||||||
AgentStreamEventPayload,
|
|
||||||
SessionOutboundMessage,
|
|
||||||
} from "@server/shared/messages";
|
|
||||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
buildAgentAttentionNotificationPayload,
|
buildAgentAttentionNotificationPayload,
|
||||||
@@ -1201,7 +1197,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
if (message.type !== "agent_stream") return;
|
if (message.type !== "agent_stream") return;
|
||||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||||
const parsedTimestamp = new Date(timestamp);
|
const parsedTimestamp = new Date(timestamp);
|
||||||
const streamEvent = event as AgentStreamEventPayload;
|
const streamEvent = event;
|
||||||
if (
|
if (
|
||||||
event.type === "turn_started" ||
|
event.type === "turn_started" ||
|
||||||
event.type === "turn_completed" ||
|
event.type === "turn_completed" ||
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export function useVoice() {
|
|||||||
export function useVoiceOptional(): VoiceContextValue | null {
|
export function useVoiceOptional(): VoiceContextValue | null {
|
||||||
const runtime = useContext(VoiceRuntimeContext);
|
const runtime = useContext(VoiceRuntimeContext);
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
runtime ? runtime.subscribe : noopSubscribe,
|
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
||||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
@@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
startVoice: runtime.startVoice,
|
startVoice: runtime.startVoice.bind(runtime),
|
||||||
stopVoice: runtime.stopVoice,
|
stopVoice: runtime.stopVoice.bind(runtime),
|
||||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
||||||
toggleMute: runtime.toggleMute,
|
toggleMute: runtime.toggleMute.bind(runtime),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ export function useVoiceTelemetry() {
|
|||||||
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
||||||
const runtime = useContext(VoiceRuntimeContext);
|
const runtime = useContext(VoiceRuntimeContext);
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
runtime ? runtime.subscribeTelemetry : noopSubscribe,
|
runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe,
|
||||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||||
);
|
);
|
||||||
|
|
||||||
return runtime ? snapshot : null;
|
return runtime ? snapshot : null;
|
||||||
@@ -140,7 +140,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
runtimeRef.current = runtime;
|
runtimeRef.current = runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = engineRef.current!;
|
const engine = engineRef.current;
|
||||||
const runtime = runtimeRef.current!;
|
const runtime = runtimeRef.current!;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("desktop attachment store", () => {
|
|||||||
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
||||||
|
|
||||||
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
||||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
|
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
|
||||||
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
||||||
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||||
path: "/managed/att_3.jpg",
|
path: "/managed/att_3.jpg",
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
||||||
import React from "react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { LocalDaemonSection } from "./desktop-updates-section";
|
|
||||||
|
|
||||||
const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({
|
|
||||||
alertMock: vi.fn(),
|
|
||||||
confirmDialogMock: vi.fn(),
|
|
||||||
settingsState: {
|
|
||||||
settings: {
|
|
||||||
releaseChannel: "stable" as const,
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: true,
|
|
||||||
keepRunningAfterQuit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateSettings: vi.fn<
|
|
||||||
(updates: {
|
|
||||||
daemon?: {
|
|
||||||
manageBuiltInDaemon?: boolean;
|
|
||||||
keepRunningAfterQuit?: boolean;
|
|
||||||
};
|
|
||||||
}) => Promise<void>
|
|
||||||
>(),
|
|
||||||
},
|
|
||||||
daemonStatusState: {
|
|
||||||
data: {
|
|
||||||
status: {
|
|
||||||
serverId: "desktop",
|
|
||||||
status: "running" as const,
|
|
||||||
listen: null,
|
|
||||||
hostname: null,
|
|
||||||
pid: 123,
|
|
||||||
home: "/tmp/paseo",
|
|
||||||
version: "1.2.3",
|
|
||||||
desktopManaged: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
logs: {
|
|
||||||
logPath: "/tmp/paseo/daemon.log",
|
|
||||||
contents: "daemon log",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null as string | null,
|
|
||||||
setStatus: vi.fn(),
|
|
||||||
refetch: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }),
|
|
||||||
Alert: { alert: alertMock },
|
|
||||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
|
||||||
React.createElement("span", null, children),
|
|
||||||
View: ({ children }: { children?: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function"
|
|
||||||
? (factory as (theme: Record<string, unknown>) => unknown)({
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 6: 24 },
|
|
||||||
borderRadius: { lg: 12 },
|
|
||||||
fontSize: { xs: 12, sm: 14 },
|
|
||||||
colors: {
|
|
||||||
foreground: "#111",
|
|
||||||
foregroundMuted: "#666",
|
|
||||||
palette: { amber: { 500: "#f59e0b" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({
|
|
||||||
theme: {
|
|
||||||
iconSize: { sm: 14 },
|
|
||||||
colors: {
|
|
||||||
foreground: "#111",
|
|
||||||
foregroundMuted: "#666",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-clipboard", () => ({
|
|
||||||
setStringAsync: vi.fn(() => Promise.resolve()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
|
||||||
return {
|
|
||||||
Activity: icon("Activity"),
|
|
||||||
ArrowUpRight: icon("ArrowUpRight"),
|
|
||||||
Copy: icon("Copy"),
|
|
||||||
FileText: icon("FileText"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/styles/settings", () => ({
|
|
||||||
settingsStyles: new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get: (_target, prop) => String(prop),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/screens/settings/settings-section", () => ({
|
|
||||||
SettingsSection: ({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
trailing,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
trailing?: React.ReactNode;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
|
||||||
"section",
|
|
||||||
null,
|
|
||||||
React.createElement("h2", null, title),
|
|
||||||
trailing,
|
|
||||||
children,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
|
||||||
AdaptiveModalSheet: ({
|
|
||||||
children,
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
visible?: boolean;
|
|
||||||
title?: string;
|
|
||||||
}) => (visible ? React.createElement("div", { "data-title": title }, children) : null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/button", () => ({
|
|
||||||
Button: ({
|
|
||||||
children,
|
|
||||||
onPress,
|
|
||||||
disabled,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onPress?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
|
||||||
"button",
|
|
||||||
{ type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel },
|
|
||||||
children,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/switch", () => ({
|
|
||||||
Switch: ({
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
disabled,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
value: boolean;
|
|
||||||
onValueChange?: (next: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement("button", {
|
|
||||||
type: "button",
|
|
||||||
role: "switch",
|
|
||||||
"aria-label": accessibilityLabel,
|
|
||||||
"aria-checked": value,
|
|
||||||
disabled,
|
|
||||||
onClick: () => onValueChange?.(!value),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/settings/desktop-settings", () => ({
|
|
||||||
useDesktopSettings: () => ({
|
|
||||||
...settingsState,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/hooks/use-daemon-status", () => ({
|
|
||||||
useDaemonStatus: () => daemonStatusState,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/confirm-dialog", () => ({
|
|
||||||
confirmDialog: confirmDialogMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/updates/desktop-updates", () => ({
|
|
||||||
isVersionMismatch: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const daemonCommandMocks = vi.hoisted(() => ({
|
|
||||||
getCliDaemonStatusMock: vi.fn(),
|
|
||||||
startDesktopDaemonMock: vi.fn(),
|
|
||||||
stopDesktopDaemonMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
|
||||||
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
|
|
||||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
|
||||||
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
|
|
||||||
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/app-version", () => ({
|
|
||||||
resolveAppVersion: vi.fn(() => "1.2.3"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("LocalDaemonSection", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
alertMock.mockReset();
|
|
||||||
confirmDialogMock.mockReset();
|
|
||||||
settingsState.settings.daemon.manageBuiltInDaemon = true;
|
|
||||||
settingsState.settings.daemon.keepRunningAfterQuit = true;
|
|
||||||
settingsState.updateSettings.mockReset();
|
|
||||||
settingsState.updateSettings.mockResolvedValue();
|
|
||||||
daemonStatusState.data.status.status = "running";
|
|
||||||
daemonStatusState.data.status.desktopManaged = true;
|
|
||||||
daemonStatusState.setStatus.mockReset();
|
|
||||||
daemonStatusState.refetch.mockReset();
|
|
||||||
daemonCommandMocks.startDesktopDaemonMock.mockReset();
|
|
||||||
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "running",
|
|
||||||
desktopManaged: true,
|
|
||||||
});
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "stopped",
|
|
||||||
});
|
|
||||||
daemonCommandMocks.getCliDaemonStatusMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the daemon toggles as switches reflecting current settings", () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Keep daemon running after quit")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Manage built-in daemon")).toBeTruthy();
|
|
||||||
|
|
||||||
const keepRunningSwitch = screen.getByRole("switch", {
|
|
||||||
name: "Keep daemon running after quit",
|
|
||||||
});
|
|
||||||
expect(keepRunningSwitch.getAttribute("aria-checked")).toBe("true");
|
|
||||||
|
|
||||||
const manageSwitch = screen.getByRole("switch", { name: "Manage built-in daemon" });
|
|
||||||
expect(manageSwitch.getAttribute("aria-checked")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render a duplicate restart-daemon control", () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Restart daemon")).toBeNull();
|
|
||||||
expect(screen.queryByText("Start daemon")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates keep-running-after-quit without changing daemon lifecycle", async () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Keep daemon running after quit" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
keepRunningAfterQuit: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(confirmDialogMock).not.toHaveBeenCalled();
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pauses built-in daemon management and persists the setting through desktop settings", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists paused management before stopping the desktop-managed daemon", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
daemonStatusState.data.status.desktopManaged = false;
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts the built-in daemon when management is re-enabled", async () => {
|
|
||||||
settingsState.settings.daemon.manageBuiltInDaemon = false;
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "running",
|
|
||||||
desktopManaged: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
startDesktopDaemon,
|
startDesktopDaemon,
|
||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
@@ -39,72 +40,42 @@ function useDaemonManagementToggle(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.manageBuiltInDaemon) {
|
setIsUpdatingDaemonManagement(true);
|
||||||
setIsUpdatingDaemonManagement(true);
|
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
||||||
void updateSettings({ manageBuiltInDaemon: true })
|
confirm: () =>
|
||||||
.then(() => startDesktopDaemon())
|
confirmDialog({
|
||||||
.then((newStatus) => {
|
title: "Pause built-in daemon",
|
||||||
setStatus(newStatus);
|
message:
|
||||||
refetch();
|
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||||
return;
|
confirmLabel: "Pause and stop",
|
||||||
})
|
cancelLabel: "Cancel",
|
||||||
.catch((error) => {
|
destructive: true,
|
||||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
}),
|
||||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||||
})
|
startDaemon: startDesktopDaemon,
|
||||||
.finally(() => {
|
stopDaemon: stopDesktopDaemon,
|
||||||
setIsUpdatingDaemonManagement(false);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmDialog({
|
|
||||||
title: "Pause built-in daemon",
|
|
||||||
message:
|
|
||||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
|
||||||
confirmLabel: "Pause and stop",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
destructive: true,
|
|
||||||
})
|
})
|
||||||
.then((confirmed) => {
|
.then((result) => {
|
||||||
if (!confirmed) {
|
if (result.kind === "cancelled") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.newStatus) {
|
||||||
setIsUpdatingDaemonManagement(true);
|
setStatus(result.newStatus);
|
||||||
|
}
|
||||||
void updateSettings({ manageBuiltInDaemon: false })
|
refetch();
|
||||||
.then(() => {
|
|
||||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
|
||||||
return stopDesktopDaemon();
|
|
||||||
}
|
|
||||||
return daemonStatus ?? null;
|
|
||||||
})
|
|
||||||
.then((newStatus) => {
|
|
||||||
if (newStatus) {
|
|
||||||
setStatus(newStatus);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to stop built-in daemon", error);
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsUpdatingDaemonManagement(false);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
settings.manageBuiltInDaemon
|
||||||
|
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
|
||||||
|
: "Unable to update built-in daemon management.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdatingDaemonManagement(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||||
|
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||||
|
|
||||||
|
const runningManagedStatus: DesktopDaemonStatus = {
|
||||||
|
serverId: "desktop",
|
||||||
|
status: "running",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: 123,
|
||||||
|
home: "/tmp/paseo",
|
||||||
|
version: "1.0.0",
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" };
|
||||||
|
|
||||||
|
function makeDeps(overrides?: {
|
||||||
|
confirm?: () => Promise<boolean>;
|
||||||
|
persistSettings?: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
|
||||||
|
startDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
|
stopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
|
}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
confirm: overrides?.confirm ?? (() => Promise.resolve(true)),
|
||||||
|
persistSettings:
|
||||||
|
overrides?.persistSettings ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("persist");
|
||||||
|
}),
|
||||||
|
startDaemon:
|
||||||
|
overrides?.startDaemon ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("start");
|
||||||
|
return { ...runningManagedStatus, status: "running" as const };
|
||||||
|
}),
|
||||||
|
stopDaemon:
|
||||||
|
overrides?.stopDaemon ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("stop");
|
||||||
|
return stoppedStatus;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("executeDaemonManagementToggle", () => {
|
||||||
|
describe("enable path (currentlyManaging: false)", () => {
|
||||||
|
it("persists the new setting then starts the daemon", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(false, null, deps);
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "enabled", newStatus: runningManagedStatus });
|
||||||
|
expect(calls).toEqual(["persist", "start"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists manageBuiltInDaemon: true", async () => {
|
||||||
|
let persisted: { manageBuiltInDaemon: boolean } | null = null;
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persisted = next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(false, null, deps);
|
||||||
|
|
||||||
|
expect(persisted).toEqual({ manageBuiltInDaemon: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disable path (currentlyManaging: true)", () => {
|
||||||
|
it("returns cancelled without changing settings when confirmation is rejected", async () => {
|
||||||
|
const persistedSettings: unknown[] = [];
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
confirm: () => Promise.resolve(false),
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persistedSettings.push(next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "cancelled" });
|
||||||
|
expect(persistedSettings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists settings BEFORE stopping the daemon", async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async () => {
|
||||||
|
callOrder.push("persist");
|
||||||
|
},
|
||||||
|
stopDaemon: async () => {
|
||||||
|
callOrder.push("stop");
|
||||||
|
return stoppedStatus;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["persist", "stop"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists manageBuiltInDaemon: false when disabling", async () => {
|
||||||
|
let persisted: { manageBuiltInDaemon: boolean } | null = null;
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persisted = next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(persisted).toEqual({ manageBuiltInDaemon: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the daemon when it is running and desktop-managed", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemon is running but not desktop-managed", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false };
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemon is stopped (regardless of desktopManaged)", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, stoppedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemonStatus is null", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, null, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||||
|
|
||||||
|
export interface DaemonManagementToggleDeps {
|
||||||
|
confirm: () => Promise<boolean>;
|
||||||
|
persistSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
|
||||||
|
startDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
|
stopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DaemonManagementToggleResult =
|
||||||
|
| { kind: "cancelled" }
|
||||||
|
| { kind: "enabled"; newStatus: DesktopDaemonStatus }
|
||||||
|
| { kind: "disabled"; newStatus: DesktopDaemonStatus | null };
|
||||||
|
|
||||||
|
export async function executeDaemonManagementToggle(
|
||||||
|
currentlyManaging: boolean,
|
||||||
|
daemonStatus: Pick<DesktopDaemonStatus, "status" | "desktopManaged"> | null,
|
||||||
|
deps: DaemonManagementToggleDeps,
|
||||||
|
): Promise<DaemonManagementToggleResult> {
|
||||||
|
if (!currentlyManaging) {
|
||||||
|
await deps.persistSettings({ manageBuiltInDaemon: true });
|
||||||
|
const newStatus = await deps.startDaemon();
|
||||||
|
return { kind: "enabled", newStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await deps.confirm();
|
||||||
|
if (!confirmed) {
|
||||||
|
return { kind: "cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings must persist before the daemon is stopped so the persisted
|
||||||
|
// state reflects what was actually applied if the stop fails.
|
||||||
|
await deps.persistSettings({ manageBuiltInDaemon: false });
|
||||||
|
|
||||||
|
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||||
|
const newStatus = await deps.stopDaemon();
|
||||||
|
return { kind: "disabled", newStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "disabled", newStatus: null };
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
|||||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||||
let binary = "";
|
let binary = "";
|
||||||
for (let index = 0; index < bytes.length; index += 1) {
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
binary += String.fromCharCode(bytes[index]!);
|
binary += String.fromCharCode(bytes[index]);
|
||||||
}
|
}
|
||||||
return globalThis.btoa(binary);
|
return globalThis.btoa(binary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export class DictationStreamSender {
|
|||||||
let sent = 0;
|
let sent = 0;
|
||||||
while (this.sendSeq < this.segments.length) {
|
while (this.sendSeq < this.segments.length) {
|
||||||
const seq = this.sendSeq;
|
const seq = this.sendSeq;
|
||||||
const audio = this.segments[seq]!;
|
const audio = this.segments[seq];
|
||||||
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
|
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
|
||||||
this.sendSeq = seq + 1;
|
this.sendSeq = seq + 1;
|
||||||
sent += 1;
|
sent += 1;
|
||||||
|
|||||||
@@ -0,0 +1,967 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
resolveDefaultModel,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
type AgentFormReducerState,
|
||||||
|
type UserModifiedFields,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [
|
||||||
|
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude test provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||||
|
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||||
|
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODEX_MODELS: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.3-codex",
|
||||||
|
label: "gpt-5.3-codex",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "xhigh",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "low" },
|
||||||
|
{ id: "xhigh", label: "xhigh", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeProviderMap(
|
||||||
|
...definitions: AgentProviderDefinition[]
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map(definitions.map((d) => [d.id, d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||||
|
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||||
|
const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION);
|
||||||
|
|
||||||
|
function makeState(
|
||||||
|
overrides: Partial<AgentFormReducerState["form"]> = {},
|
||||||
|
modified: Partial<UserModifiedFields> = {},
|
||||||
|
): AgentFormReducerState {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveDefaultModel", () => {
|
||||||
|
it("returns null for empty or null input", () => {
|
||||||
|
expect(resolveDefaultModel(null)).toBeNull();
|
||||||
|
expect(resolveDefaultModel([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the model marked isDefault", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first model when none is marked default", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: false },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveThinkingOptionId", () => {
|
||||||
|
it("returns empty string when model has no thinking options", () => {
|
||||||
|
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||||
|
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsWithoutThinking,
|
||||||
|
modelId: "claude-sonnet-4-6",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the requested option when it is valid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "low",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaultThinkingOptionId when requested option is invalid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "invalid",
|
||||||
|
}),
|
||||||
|
).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to first option when no default and requested is invalid", () => {
|
||||||
|
const modelsNoDefault: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "m",
|
||||||
|
label: "M",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsNoDefault,
|
||||||
|
modelId: "m",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combineInitialValues", () => {
|
||||||
|
it("returns undefined when no initial values and no initial server id", () => {
|
||||||
|
expect(combineInitialValues(undefined, null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
||||||
|
const combined = combineInitialValues({}, null);
|
||||||
|
expect(combined).toEqual({});
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects serverId from options when provided", () => {
|
||||||
|
expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other initial values without forcing serverId", () => {
|
||||||
|
const combined = combineInitialValues({ workingDir: "/repo" }, null);
|
||||||
|
expect(combined).toEqual({ workingDir: "/repo" });
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
||||||
|
expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null });
|
||||||
|
expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
||||||
|
serverId: "daemon-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeSelectedComposerPreferences", () => {
|
||||||
|
it("stores the selected model for the selected provider", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { model: "gpt-5.4" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing provider preferences when the selected model changes", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "auto",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low", "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitions", () => {
|
||||||
|
it("returns empty array when snapshot data is unavailable", () => {
|
||||||
|
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||||
|
expect(buildProviderDefinitions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds provider definitions from snapshot metadata", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "zai",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildProviderDefinitions(entries)).toEqual([
|
||||||
|
{
|
||||||
|
id: "zai",
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFormState", () => {
|
||||||
|
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
|
||||||
|
bothProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "default" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first thinking option when model exposes options without a provider default", () => {
|
||||||
|
const claudeWithThinking: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
||||||
|
claudeWithThinking,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "claude" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||||
|
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: loadingEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: true,
|
||||||
|
model: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("full-access");
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
selectableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
||||||
|
expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||||
|
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "unavailable",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: unavailableEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{ ...INITIAL_USER_MODIFIED, provider: true },
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
new Map<AgentProvider, AgentProviderDefinition>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAgentForm", () => {
|
||||||
|
describe("RESOLVE", () => {
|
||||||
|
it("applies resolved provider and mode when no user modifications", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same state reference when nothing changed", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override user-modified provider", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "claude" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: bothProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID", () => {
|
||||||
|
it("updates serverId without marking it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
expect(next.userModified.serverId).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID_FROM_USER", () => {
|
||||||
|
it("updates serverId and marks it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-2");
|
||||||
|
expect(next.userModified.serverId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_FROM_USER", () => {
|
||||||
|
it("switches provider, picks preferred model and mode, marks provider modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider defaults when no prefs", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||||
|
it("sets provider, model, and default mode; marks both modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is empty", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects default thinking option for the chosen model", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODE_FROM_USER", () => {
|
||||||
|
it("updates modeId and marks it modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_MODE_FROM_USER", modeId: "full-access" });
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.modeId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODEL_FROM_USER", () => {
|
||||||
|
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user-chosen thinking option when switching to same model", () => {
|
||||||
|
const state = makeState(
|
||||||
|
{ provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ thinkingOptionId: true },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is blank", () => {
|
||||||
|
const state = makeState({ provider: "codex" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: " ",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||||
|
it("updates thinkingOptionId and marks it modified", () => {
|
||||||
|
const state = makeState({ thinkingOptionId: "xhigh" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_THINKING_OPTION_FROM_USER",
|
||||||
|
thinkingOptionId: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
expect(next.userModified.thinkingOptionId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR", () => {
|
||||||
|
it("updates workingDir without marking it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" });
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR_FROM_USER", () => {
|
||||||
|
it("updates workingDir and marks it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_WORKING_DIR_FROM_USER",
|
||||||
|
value: "/home/user/proj",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AUTO_SELECT_SERVER", () => {
|
||||||
|
it("sets serverId when currently null", () => {
|
||||||
|
const state = makeState({ serverId: null });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an already-set serverId", () => {
|
||||||
|
const state = makeState({ serverId: "existing" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RESET", () => {
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, { type: "RESET" });
|
||||||
|
|
||||||
|
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||||
|
expect(next.form).toEqual(state.form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMap", () => {
|
||||||
|
it("builds a map from provider id to definition", () => {
|
||||||
|
const map = buildProviderDefinitionMap([TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION]);
|
||||||
|
expect(map.get("codex")).toBe(TEST_CODEX_DEFINITION);
|
||||||
|
expect(map.get("claude")).toBe(TEST_CLAUDE_DEFINITION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMapForStatuses", () => {
|
||||||
|
it("returns all definitions when no snapshot entries", () => {
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: undefined,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION],
|
||||||
|
statuses: new Set(["ready"]),
|
||||||
|
});
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters to only matching-status enabled providers", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "Codex",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: "Claude",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION],
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
mergeProviderPreferences,
|
||||||
|
type FormPreferences,
|
||||||
|
type ProviderPreferences,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
|
export interface FormInitialValues {
|
||||||
|
serverId?: string | null;
|
||||||
|
provider?: AgentProvider;
|
||||||
|
modeId?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
serverId: string | null;
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
modeId: string;
|
||||||
|
model: string;
|
||||||
|
thinkingOptionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserModifiedFields {
|
||||||
|
serverId: boolean;
|
||||||
|
provider: boolean;
|
||||||
|
modeId: boolean;
|
||||||
|
model: boolean;
|
||||||
|
thinkingOptionId: boolean;
|
||||||
|
workingDir: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFormReducerState {
|
||||||
|
form: FormState;
|
||||||
|
userModified: UserModifiedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||||
|
|
||||||
|
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
||||||
|
"ready",
|
||||||
|
"loading",
|
||||||
|
]);
|
||||||
|
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
|
export type AgentFormAction =
|
||||||
|
| {
|
||||||
|
type: "RESOLVE";
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}
|
||||||
|
| { type: "SET_SERVER_ID"; value: string | null }
|
||||||
|
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
modelId: string;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_MODE_FROM_USER"; modeId: string }
|
||||||
|
| {
|
||||||
|
type: "SET_MODEL_FROM_USER";
|
||||||
|
modelId: string;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
|
| { type: "SET_WORKING_DIR"; value: string }
|
||||||
|
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
|
||||||
|
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
||||||
|
| { type: "RESET" };
|
||||||
|
|
||||||
|
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
|
return typeof modelId === "string" ? modelId.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
modelId: string,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
const normalizedModelId = modelId.trim();
|
||||||
|
if (!normalizedModelId) return null;
|
||||||
|
return (
|
||||||
|
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||||
|
resolveDefaultModel(availableModels)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) return "";
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSelectedComposerPreferences(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: AgentProvider;
|
||||||
|
updates: Partial<ProviderPreferences>;
|
||||||
|
}): FormPreferences {
|
||||||
|
return mergeProviderPreferences({
|
||||||
|
preferences: args.preferences,
|
||||||
|
provider: args.provider,
|
||||||
|
updates: args.updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combineInitialValues(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
initialServerId: string | null,
|
||||||
|
): FormInitialValues | undefined {
|
||||||
|
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
||||||
|
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
||||||
|
|
||||||
|
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitServerId) {
|
||||||
|
return { ...initialValues, serverId: initialValues?.serverId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverIdFromOptions !== undefined) {
|
||||||
|
return { ...initialValues, serverId: serverIdFromOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
||||||
|
return (
|
||||||
|
prev.serverId !== next.serverId ||
|
||||||
|
prev.provider !== next.provider ||
|
||||||
|
prev.modeId !== next.modeId ||
|
||||||
|
prev.model !== next.model ||
|
||||||
|
prev.thinkingOptionId !== next.thinkingOptionId ||
|
||||||
|
prev.workingDir !== next.workingDir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMap(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMapForStatuses(args: {
|
||||||
|
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||||
|
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
if (!args.snapshotEntries?.length) {
|
||||||
|
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProviders = new Set(
|
||||||
|
args.snapshotEntries
|
||||||
|
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||||
|
.map((entry) => entry.provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildProviderDefinitionMap(
|
||||||
|
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProvider(input: {
|
||||||
|
currentProvider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}): AgentProvider | null {
|
||||||
|
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
||||||
|
if (userModified) {
|
||||||
|
if (
|
||||||
|
currentProvider &&
|
||||||
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(currentProvider)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
|
return initialValues.provider;
|
||||||
|
}
|
||||||
|
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||||
|
return preferences.provider;
|
||||||
|
}
|
||||||
|
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModeId(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModeId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModeId;
|
||||||
|
if (!provider) return "";
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (
|
||||||
|
typeof initialValues?.modeId === "string" &&
|
||||||
|
initialValues.modeId.length > 0 &&
|
||||||
|
validModeIds.includes(initialValues.modeId)
|
||||||
|
) {
|
||||||
|
return initialValues.modeId;
|
||||||
|
}
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelField(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModel: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModel;
|
||||||
|
if (!provider) return "";
|
||||||
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
if (initialModel) {
|
||||||
|
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
||||||
|
}
|
||||||
|
if (preferredModel) {
|
||||||
|
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOption(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentThinkingOptionId: string;
|
||||||
|
modelId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (!provider) return "";
|
||||||
|
if (userModified) return currentThinkingOptionId;
|
||||||
|
const initialThinkingOptionId =
|
||||||
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
|
? initialValues.thinkingOptionId.trim()
|
||||||
|
: "";
|
||||||
|
const effectiveModelId = modelId.trim();
|
||||||
|
const preferredThinking = effectiveModelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||||
|
if (preferredThinking.length > 0) return preferredThinking;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFormState(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
preferences: FormPreferences | null,
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
userModified: UserModifiedFields,
|
||||||
|
currentState: FormState,
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||||
|
): FormState {
|
||||||
|
const result = { ...currentState };
|
||||||
|
|
||||||
|
result.provider = resolveProvider({
|
||||||
|
currentProvider: result.provider,
|
||||||
|
userModified: userModified.provider,
|
||||||
|
initialValues,
|
||||||
|
preferences,
|
||||||
|
allowedProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||||
|
const providerPrefs = result.provider
|
||||||
|
? preferences?.providerPreferences?.[result.provider]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
result.modeId = resolveModeId({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.modeId,
|
||||||
|
currentModeId: result.modeId,
|
||||||
|
initialValues,
|
||||||
|
providerDef,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.model = resolveModelField({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.model,
|
||||||
|
currentModel: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
availableModels,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.thinkingOptionId = resolveThinkingOption({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.thinkingOptionId,
|
||||||
|
currentThinkingOptionId: result.thinkingOptionId,
|
||||||
|
modelId: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.provider && availableModels) {
|
||||||
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: result.model,
|
||||||
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
||||||
|
result.serverId = initialValues.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
||||||
|
result.workingDir = initialValues.workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModelForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs } = input;
|
||||||
|
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
||||||
|
return preferredModel;
|
||||||
|
}
|
||||||
|
return defaultModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModeForProvider(input: {
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerDef, providerPrefs } = input;
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextThinkingOptionForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
modelId: string;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs, modelId } = input;
|
||||||
|
const preferredThinking = modelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
return resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId,
|
||||||
|
requestedThinkingOptionId: preferredThinking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentForm(
|
||||||
|
state: AgentFormReducerState,
|
||||||
|
action: AgentFormAction,
|
||||||
|
): AgentFormReducerState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "RESOLVE": {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
action.initialValues,
|
||||||
|
action.preferences,
|
||||||
|
action.availableModels,
|
||||||
|
state.userModified,
|
||||||
|
state.form,
|
||||||
|
action.allowedProviderMap,
|
||||||
|
);
|
||||||
|
if (!hasFormStateChanged(state.form, resolved)) return state;
|
||||||
|
return { ...state, form: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SERVER_ID":
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.value } };
|
||||||
|
|
||||||
|
case "SET_SERVER_ID_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, serverId: action.value },
|
||||||
|
userModified: { ...state.userModified, serverId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_PROVIDER_FROM_USER": {
|
||||||
|
const nextModelId = pickNextModelForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextModeId = pickNextModeForProvider({
|
||||||
|
providerDef: action.providerDef,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
modelId: nextModelId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
modeId: nextModeId,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
model: nextModelId,
|
||||||
|
modeId: action.providerDef?.defaultModeId ?? "",
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_MODE_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, modeId: action.modeId },
|
||||||
|
userModified: { ...state.userModified, modeId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
||||||
|
? state.form.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
|
userModified: { ...state.userModified, thinkingOptionId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR":
|
||||||
|
return { ...state, form: { ...state.form, workingDir: action.value } };
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, workingDir: action.value },
|
||||||
|
userModified: { ...state.userModified, workingDir: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "AUTO_SELECT_SERVER":
|
||||||
|
if (state.form.serverId) return state;
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
||||||
|
|
||||||
|
case "RESET":
|
||||||
|
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { FormPreferences } from "./use-form-preferences";
|
|
||||||
import { useAgentFormState } from "./use-agent-form-state";
|
|
||||||
|
|
||||||
const { mocks } = vi.hoisted(() => ({
|
|
||||||
mocks: {
|
|
||||||
preferences: {} as FormPreferences,
|
|
||||||
isPreferencesLoading: false,
|
|
||||||
snapshotEntries: [] as ProviderSnapshotEntry[],
|
|
||||||
updatePreferences: vi.fn(),
|
|
||||||
refreshSnapshot: vi.fn(),
|
|
||||||
refetchSnapshotIfStale: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "host-1" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-form-preferences", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("./use-form-preferences")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useFormPreferences: () => ({
|
|
||||||
preferences: mocks.preferences,
|
|
||||||
isLoading: mocks.isPreferencesLoading,
|
|
||||||
updatePreferences: mocks.updatePreferences,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./use-providers-snapshot", () => ({
|
|
||||||
useProvidersSnapshot: () => ({
|
|
||||||
entries: mocks.snapshotEntries,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
isRefreshing: false,
|
|
||||||
error: null,
|
|
||||||
supportsSnapshot: true,
|
|
||||||
refresh: mocks.refreshSnapshot,
|
|
||||||
refetchIfStale: mocks.refetchSnapshotIfStale,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useAgentFormState live preference hydration", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
|
||||||
url: "http://localhost",
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "document", {
|
|
||||||
value: dom.window.document,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: dom.window,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
|
||||||
value: dom.window.navigator,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {};
|
|
||||||
mocks.isPreferencesLoading = false;
|
|
||||||
mocks.snapshotEntries = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{
|
|
||||||
id: "full-access",
|
|
||||||
label: "Full Access",
|
|
||||||
icon: "ShieldAlert",
|
|
||||||
colorTier: "dangerous",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4",
|
|
||||||
label: "gpt-5.4",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "low",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "xhigh", label: "XHigh" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
label: "Sonnet 4.6",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mocks.updatePreferences.mockReset();
|
|
||||||
mocks.refreshSnapshot.mockReset();
|
|
||||||
mocks.refetchSnapshotIfStale.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the latest preferences when a separate composer hydrates later", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
|
|
||||||
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
|
|
||||||
expect(result.current.selectedMode).toBe("default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { __private__ } from "./use-agent-form-state";
|
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import type {
|
|
||||||
AgentModelDefinition,
|
|
||||||
AgentProvider,
|
|
||||||
ProviderSnapshotEntry,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
|
||||||
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
|
||||||
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeProviderMap(
|
|
||||||
...definitions: AgentProviderDefinition[]
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
|
||||||
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
|
||||||
|
|
||||||
describe("useAgentFormState", () => {
|
|
||||||
describe("buildProviderDefinitions", () => {
|
|
||||||
it("returns empty array when snapshot data is unavailable", () => {
|
|
||||||
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
|
||||||
expect(buildProviderDefinitions([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds custom provider definitions from snapshot metadata", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "zai",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const definitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
expect(definitions).toEqual([
|
|
||||||
{
|
|
||||||
id: "zai",
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Always Ask",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.combineInitialValues", () => {
|
|
||||||
it("returns undefined when no initial values and no initial server id", () => {
|
|
||||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
|
||||||
const combined = __private__.combineInitialValues({}, null);
|
|
||||||
expect(combined).toEqual({});
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects serverId from options when provided", () => {
|
|
||||||
expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps other initial values without forcing serverId", () => {
|
|
||||||
const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null);
|
|
||||||
expect(combined).toEqual({ workingDir: "/repo" });
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
|
||||||
expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-2",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.mergeSelectedComposerPreferences", () => {
|
|
||||||
it("stores the selected model for the selected provider", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves existing provider preferences when the selected model changes", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores mode and thinking preferences without dropping the selected model", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.resolveFormState", () => {
|
|
||||||
const codexModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.3-codex",
|
|
||||||
label: "gpt-5.3-codex",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "xhigh",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "low" },
|
|
||||||
{ id: "xhigh", label: "xhigh", isDefault: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an explicit initial thinking option when it is valid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the first thinking option when the model exposes options without a provider default", () => {
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "default",
|
|
||||||
label: "Default (Sonnet 4.6)",
|
|
||||||
isDefault: true,
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "medium", label: "Medium" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
|
||||||
claudeModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "claude",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("default");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
|
||||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "loading",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: loadingEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: true,
|
|
||||||
model: true,
|
|
||||||
thinkingOptionId: true,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("full-access");
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
selectableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
|
|
||||||
const snapshotProviderStates = [
|
|
||||||
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
|
|
||||||
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
|
||||||
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
|
|
||||||
expect(snapshotProviderStates).toEqual([
|
|
||||||
{ provider: "codex", enabled: true },
|
|
||||||
{ provider: "claude", enabled: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
|
||||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "unavailable",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: unavailableEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -13,49 +13,26 @@ import {
|
|||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
|
||||||
} from "./use-form-preferences";
|
} from "./use-form-preferences";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveEffectiveModel,
|
||||||
|
normalizeSelectedModelId,
|
||||||
|
resolveDefaultModelId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
RESOLVABLE_PROVIDER_STATUSES,
|
||||||
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
|
type FormInitialValues,
|
||||||
|
type FormState,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
export type { FormInitialValues } from "./resolve-agent-form";
|
||||||
export interface FormInitialValues {
|
|
||||||
serverId?: string | null;
|
|
||||||
provider?: AgentProvider;
|
|
||||||
modeId?: string | null;
|
|
||||||
model?: string | null;
|
|
||||||
thinkingOptionId?: string | null;
|
|
||||||
workingDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks which fields the user has explicitly modified in this session
|
export interface UseAgentFormStateOptions {
|
||||||
interface UserModifiedFields {
|
|
||||||
serverId: boolean;
|
|
||||||
provider: boolean;
|
|
||||||
modeId: boolean;
|
|
||||||
model: boolean;
|
|
||||||
thinkingOptionId: boolean;
|
|
||||||
workingDir: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Internal form state
|
|
||||||
interface FormState {
|
|
||||||
serverId: string | null;
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
modeId: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId: string;
|
|
||||||
workingDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAgentFormStateOptions {
|
|
||||||
initialServerId?: string | null;
|
initialServerId?: string | null;
|
||||||
initialValues?: FormInitialValues;
|
initialValues?: FormInitialValues;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
@@ -97,316 +74,6 @@ export interface UseAgentFormStateResult {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
|
||||||
if (!normalized) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
|
||||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
modelId: string,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedModelId = modelId.trim();
|
|
||||||
if (!normalizedModelId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
|
||||||
resolveDefaultModel(availableModels)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOptionId(args: {
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
modelId: string;
|
|
||||||
requestedThinkingOptionId: string;
|
|
||||||
}): string {
|
|
||||||
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
|
||||||
if (thinkingOptions.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
|
||||||
if (
|
|
||||||
normalizedThinkingOptionId &&
|
|
||||||
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
|
||||||
) {
|
|
||||||
return normalizedThinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSelectedComposerPreferences(args: {
|
|
||||||
preferences: FormPreferences;
|
|
||||||
provider: AgentProvider;
|
|
||||||
updates: Partial<ProviderPreferences>;
|
|
||||||
}): FormPreferences {
|
|
||||||
return mergeProviderPreferences({
|
|
||||||
preferences: args.preferences,
|
|
||||||
provider: args.provider,
|
|
||||||
updates: args.updates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure function that resolves form state from multiple data sources.
|
|
||||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
|
||||||
*
|
|
||||||
* Only resolves fields that haven't been user-modified.
|
|
||||||
*/
|
|
||||||
function resolveProvider(input: {
|
|
||||||
currentProvider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
preferences: FormPreferences | null;
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
|
||||||
}): AgentProvider | null {
|
|
||||||
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
|
||||||
if (userModified) {
|
|
||||||
if (
|
|
||||||
currentProvider &&
|
|
||||||
allowedProviderMap.size > 0 &&
|
|
||||||
!allowedProviderMap.has(currentProvider)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
|
||||||
return initialValues.provider;
|
|
||||||
}
|
|
||||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) {
|
|
||||||
return preferences.provider as AgentProvider;
|
|
||||||
}
|
|
||||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModeId(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModeId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModeId;
|
|
||||||
if (!provider) return "";
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (
|
|
||||||
typeof initialValues?.modeId === "string" &&
|
|
||||||
initialValues.modeId.length > 0 &&
|
|
||||||
validModeIds.includes(initialValues.modeId)
|
|
||||||
) {
|
|
||||||
return initialValues.modeId;
|
|
||||||
}
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelField(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModel: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModel;
|
|
||||||
if (!provider) return "";
|
|
||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
|
||||||
if (initialModel) {
|
|
||||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
|
||||||
}
|
|
||||||
if (preferredModel) {
|
|
||||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOption(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentThinkingOptionId: string;
|
|
||||||
modelId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (!provider) return "";
|
|
||||||
if (userModified) return currentThinkingOptionId;
|
|
||||||
const initialThinkingOptionId =
|
|
||||||
typeof initialValues?.thinkingOptionId === "string"
|
|
||||||
? initialValues.thinkingOptionId.trim()
|
|
||||||
: "";
|
|
||||||
const effectiveModelId = modelId.trim();
|
|
||||||
const preferredThinking = effectiveModelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
|
||||||
if (preferredThinking.length > 0) return preferredThinking;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFormState(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
preferences: FormPreferences | null,
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
userModified: UserModifiedFields,
|
|
||||||
currentState: FormState,
|
|
||||||
validServerIds: Set<string>,
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
|
||||||
): FormState {
|
|
||||||
const result = { ...currentState };
|
|
||||||
|
|
||||||
result.provider = resolveProvider({
|
|
||||||
currentProvider: result.provider,
|
|
||||||
userModified: userModified.provider,
|
|
||||||
initialValues,
|
|
||||||
preferences,
|
|
||||||
allowedProviderMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
|
||||||
const providerPrefs = result.provider
|
|
||||||
? preferences?.providerPreferences?.[result.provider]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
result.modeId = resolveModeId({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.modeId,
|
|
||||||
currentModeId: result.modeId,
|
|
||||||
initialValues,
|
|
||||||
providerDef,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.model = resolveModelField({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.model,
|
|
||||||
currentModel: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
availableModels,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.thinkingOptionId = resolveThinkingOption({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.thinkingOptionId,
|
|
||||||
currentThinkingOptionId: result.thinkingOptionId,
|
|
||||||
modelId: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.provider && availableModels) {
|
|
||||||
result.thinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: result.model,
|
|
||||||
requestedThinkingOptionId: result.thinkingOptionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
|
||||||
result.serverId = initialValues.serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
|
||||||
result.workingDir = initialValues.workingDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function combineInitialValues(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
initialServerId: string | null,
|
|
||||||
): FormInitialValues | undefined {
|
|
||||||
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
|
||||||
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
|
||||||
|
|
||||||
// If nobody provided initial values or an explicit serverId, let preferences drive defaults.
|
|
||||||
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasExplicitServerId) {
|
|
||||||
return { ...initialValues, serverId: initialValues?.serverId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverIdFromOptions !== undefined) {
|
|
||||||
return { ...initialValues, serverId: serverIdFromOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
|
||||||
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
|
||||||
|
|
||||||
function buildProviderDefinitionMap(
|
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
providerDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProviderDefinitionMapForStatuses(args: {
|
|
||||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
|
||||||
}): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
if (!args.snapshotEntries?.length) {
|
|
||||||
return buildProviderDefinitionMap(args.providerDefinitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingProviders = new Set(
|
|
||||||
args.snapshotEntries
|
|
||||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
|
|
||||||
.map((entry) => entry.provider),
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildProviderDefinitionMap(
|
|
||||||
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
|
||||||
|
|
||||||
function shouldAutoSelectServerId(input: {
|
function shouldAutoSelectServerId(input: {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCreateFlow: boolean;
|
isCreateFlow: boolean;
|
||||||
@@ -434,59 +101,6 @@ function shouldAutoSelectServerId(input: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
|
||||||
return (
|
|
||||||
prev.serverId !== next.serverId ||
|
|
||||||
prev.provider !== next.provider ||
|
|
||||||
prev.modeId !== next.modeId ||
|
|
||||||
prev.model !== next.model ||
|
|
||||||
prev.thinkingOptionId !== next.thinkingOptionId ||
|
|
||||||
prev.workingDir !== next.workingDir
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModelForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs } = input;
|
|
||||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
|
||||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
|
||||||
return preferredModel;
|
|
||||||
}
|
|
||||||
return defaultModelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModeForProvider(input: {
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerDef, providerPrefs } = input;
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextThinkingOptionForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
modelId: string;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs, modelId } = input;
|
|
||||||
const preferredThinking = modelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
return resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId,
|
|
||||||
requestedThinkingOptionId: preferredThinking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSelectedProviderModes(input: {
|
function resolveSelectedProviderModes(input: {
|
||||||
selectedEntry: ProviderSnapshotEntry | null;
|
selectedEntry: ProviderSnapshotEntry | null;
|
||||||
provider: AgentProvider | null;
|
provider: AgentProvider | null;
|
||||||
@@ -552,34 +166,35 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||||
|
|
||||||
// Track which fields the user has explicitly modified
|
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
resolveAgentForm,
|
||||||
|
initialServerId,
|
||||||
|
(serverId) => ({
|
||||||
|
form: {
|
||||||
|
serverId,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
userModified: INITIAL_USER_MODIFIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Form state
|
const reducerStateRef = useRef({ form: formState, userModified });
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
|
||||||
serverId: initialServerId,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
}));
|
|
||||||
const formStateRef = useRef(formState);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formStateRef.current = formState;
|
reducerStateRef.current = { form: formState, userModified };
|
||||||
}, [formState]);
|
}, [formState, userModified]);
|
||||||
|
|
||||||
// Track if we've done initial resolution (to avoid flickering)
|
|
||||||
const hasResolvedRef = useRef(false);
|
const hasResolvedRef = useRef(false);
|
||||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
// Reset user modifications when form becomes invisible
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setUserModified(INITIAL_USER_MODIFIED);
|
dispatch({ type: "RESET" });
|
||||||
hasResolvedRef.current = false;
|
hasResolvedRef.current = false;
|
||||||
hydrationPreferencesRef.current = null;
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -644,12 +259,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
const combinedInitialValues = useMemo(
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
() => combineInitialValues(initialValues, initialServerId),
|
||||||
return combineInitialValues(initialValues, initialServerId);
|
[initialValues, initialServerId],
|
||||||
}, [initialValues, initialServerId]);
|
);
|
||||||
|
|
||||||
// Resolve form state when data sources change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isCreateFlow) {
|
if (!isVisible || !isCreateFlow) {
|
||||||
return;
|
return;
|
||||||
@@ -664,19 +278,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
const resolved = resolveFormState(
|
dispatch({
|
||||||
combinedInitialValues,
|
type: "RESOLVE",
|
||||||
hydrationPreferences,
|
initialValues: combinedInitialValues,
|
||||||
|
preferences: hydrationPreferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||||
formStateRef.current,
|
});
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasFormStateChanged(formStateRef.current, resolved)) {
|
|
||||||
setFormState(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasResolvedRef.current = true;
|
hasResolvedRef.current = true;
|
||||||
}, [
|
}, [
|
||||||
@@ -686,15 +294,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
combinedInitialValues,
|
combinedInitialValues,
|
||||||
preferences,
|
preferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
|
||||||
// - no URL override
|
|
||||||
// - no stored preference applied
|
|
||||||
// - user hasn't manually picked a host in this session
|
|
||||||
const onlineServerIdsKey = onlineServerIds.join("|");
|
const onlineServerIdsKey = onlineServerIds.join("|");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canAutoSelectServerId = shouldAutoSelectServerId({
|
const canAutoSelectServerId = shouldAutoSelectServerId({
|
||||||
@@ -704,14 +306,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
hasResolved: hasResolvedRef.current,
|
hasResolved: hasResolvedRef.current,
|
||||||
userModifiedServerId: userModified.serverId,
|
userModifiedServerId: userModified.serverId,
|
||||||
initialServerId: combinedInitialValues?.serverId,
|
initialServerId: combinedInitialValues?.serverId,
|
||||||
currentServerId: formStateRef.current.serverId,
|
currentServerId: reducerStateRef.current.form.serverId,
|
||||||
});
|
});
|
||||||
if (!canAutoSelectServerId) return;
|
if (!canAutoSelectServerId) return;
|
||||||
|
|
||||||
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
|
|
||||||
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
|
dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate });
|
||||||
}, [
|
}, [
|
||||||
combinedInitialValues?.serverId,
|
combinedInitialValues?.serverId,
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
@@ -723,10 +325,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// User setters - mark fields as modified and persist to preferences
|
|
||||||
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
@@ -738,24 +338,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs });
|
dispatch({
|
||||||
const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs });
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
|
||||||
providerModels,
|
|
||||||
providerPrefs,
|
|
||||||
modelId: nextModelId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
|
||||||
void updatePreferences({ provider });
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
provider,
|
provider,
|
||||||
modeId: nextModeId,
|
providerModels,
|
||||||
model: nextModelId,
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerPrefs,
|
||||||
}));
|
});
|
||||||
|
void updatePreferences({ provider });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -774,20 +364,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
dispatch({
|
||||||
...prev,
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
provider,
|
provider,
|
||||||
model: nextModelId,
|
modelId,
|
||||||
modeId: providerDef?.defaultModeId ?? "",
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerModels,
|
||||||
}));
|
});
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -803,9 +387,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -823,23 +406,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: userModified.thinkingOptionId
|
|
||||||
? formStateRef.current.thinkingOptionId
|
|
||||||
: "",
|
|
||||||
});
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
model: nextModelId,
|
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
|
||||||
}));
|
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -851,15 +422,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[availableModels, updatePreferences, userModified.thinkingOptionId],
|
[availableModels, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
const modelId = formStateRef.current.model;
|
|
||||||
if (provider && modelId) {
|
if (provider && modelId) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -878,16 +447,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSelectedServerId = useCallback((value: string | null) => {
|
const setSelectedServerId = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
@@ -895,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const refetchProviderModelsIfStale = useCallback(() => {
|
const refetchProviderModelsIfStale = useCallback(() => {
|
||||||
refetchSnapshotIfStale(formStateRef.current.provider);
|
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
|
||||||
}, [refetchSnapshotIfStale]);
|
}, [refetchSnapshotIfStale]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
@@ -993,15 +561,4 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
|
||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
export const __private__ = {
|
|
||||||
buildProviderDefinitionMap,
|
|
||||||
buildProviderDefinitionMapForStatuses,
|
|
||||||
combineInitialValues,
|
|
||||||
mergeSelectedComposerPreferences,
|
|
||||||
resolveDefaultModel,
|
|
||||||
resolveFormState,
|
|
||||||
resolveThinkingOptionId,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||||
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
|
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
export interface DraftStorage {
|
||||||
|
getItem(key: string): Promise<string | null>;
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDraft {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInputDraftCore {
|
||||||
|
load(initialCwd?: string): Promise<StoredDraft | null>;
|
||||||
|
save(draft: StoredDraft): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgentInputDraftCore(input: {
|
||||||
|
storage: DraftStorage;
|
||||||
|
storageKey: string;
|
||||||
|
}): AgentInputDraftCore {
|
||||||
|
return {
|
||||||
|
async load(initialCwd?: string) {
|
||||||
|
const json = await input.storage.getItem(input.storageKey);
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||||
|
attachments: Array.isArray(parsed.attachments)
|
||||||
|
? (parsed.attachments as UserComposerAttachment[])
|
||||||
|
: [],
|
||||||
|
cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save(draft) {
|
||||||
|
await input.storage.setItem(input.storageKey, JSON.stringify(draft));
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await input.storage.removeItem(input.storageKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftKeyContext {
|
||||||
|
selectedServerId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||||
|
|
||||||
|
export function resolveDraftKey(input: {
|
||||||
|
draftKey: DraftKeyInput;
|
||||||
|
selectedServerId: string | null;
|
||||||
|
}): string {
|
||||||
|
if (typeof input.draftKey === "function") {
|
||||||
|
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||||
|
}
|
||||||
|
return input.draftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveComposerModelId(input: {
|
||||||
|
selectedModel: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
}): string {
|
||||||
|
return input.selectedModel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveComposerThinkingOptionId(input: {
|
||||||
|
selectedThinkingOptionId: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
effectiveModelId: string;
|
||||||
|
}): string {
|
||||||
|
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||||
|
if (selectedThinkingOptionId) {
|
||||||
|
return selectedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModelDefinition =
|
||||||
|
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraftComposerCommandConfig(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
cwd: string;
|
||||||
|
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||||
|
selectedMode: string;
|
||||||
|
effectiveModelId: string;
|
||||||
|
effectiveThinkingOptionId: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}): DraftCommandConfig | undefined {
|
||||||
|
const cwd = input.cwd.trim();
|
||||||
|
if (!input.provider || !cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: input.provider,
|
||||||
|
cwd,
|
||||||
|
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||||
|
? { modeId: input.selectedMode }
|
||||||
|
: {}),
|
||||||
|
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||||
|
...(input.effectiveThinkingOptionId
|
||||||
|
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraftStatusControls(input: {
|
||||||
|
formState: UseAgentFormStateResult;
|
||||||
|
features?: DraftAgentStatusBarProps["features"];
|
||||||
|
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
|
||||||
|
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
|
||||||
|
}): DraftAgentStatusBarProps {
|
||||||
|
const { formState, features, onSetFeature, onDropdownClose } = input;
|
||||||
|
return {
|
||||||
|
providerDefinitions: formState.providerDefinitions,
|
||||||
|
selectedProvider: formState.selectedProvider,
|
||||||
|
onSelectProvider: formState.setProviderFromUser,
|
||||||
|
modeOptions: formState.modeOptions,
|
||||||
|
selectedMode: formState.selectedMode,
|
||||||
|
onSelectMode: formState.setModeFromUser,
|
||||||
|
models: formState.availableModels,
|
||||||
|
selectedModel: formState.selectedModel,
|
||||||
|
onSelectModel: formState.setModelFromUser,
|
||||||
|
isModelLoading: formState.isModelLoading,
|
||||||
|
allProviderModels: formState.allProviderModels,
|
||||||
|
isAllModelsLoading: formState.isAllModelsLoading,
|
||||||
|
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||||
|
thinkingOptions: formState.availableThinkingOptions,
|
||||||
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||||
|
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDraftContent(input: {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areAttachmentsEqual(input: {
|
||||||
|
left: UserComposerAttachment[];
|
||||||
|
right: UserComposerAttachment[];
|
||||||
|
}): boolean {
|
||||||
|
if (input.left.length !== input.right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.left.every((attachment, index) => {
|
||||||
|
const other = input.right[index];
|
||||||
|
return JSON.stringify(attachment) === JSON.stringify(other);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,194 +1,188 @@
|
|||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
createAgentInputDraftCore,
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
} from "./use-agent-input-draft-core";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
function makeStorage() {
|
||||||
default: {
|
const map = new Map<string, string>();
|
||||||
getItem: async () => null,
|
return {
|
||||||
setItem: async () => undefined,
|
getItem: async (key: string) => map.get(key) ?? null,
|
||||||
removeItem: async () => undefined,
|
setItem: async (key: string, value: string) => {
|
||||||
},
|
map.set(key, value);
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/attachments/service", () => ({
|
|
||||||
garbageCollectAttachments: async () => undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-agent-form-state", () => ({
|
|
||||||
useAgentFormState: () => ({
|
|
||||||
selectedServerId: "host-1",
|
|
||||||
setSelectedServerId: () => undefined,
|
|
||||||
setSelectedServerIdFromUser: () => undefined,
|
|
||||||
selectedProvider: "codex",
|
|
||||||
setProviderFromUser: () => undefined,
|
|
||||||
selectedMode: "auto",
|
|
||||||
setModeFromUser: () => undefined,
|
|
||||||
selectedModel: "",
|
|
||||||
setModelFromUser: () => undefined,
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
setThinkingOptionFromUser: () => undefined,
|
|
||||||
workingDir: "/repo",
|
|
||||||
setWorkingDir: () => undefined,
|
|
||||||
setWorkingDirFromUser: () => undefined,
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
|
||||||
providerDefinitionMap: new Map(),
|
|
||||||
agentDefinition: undefined,
|
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
availableModels: [],
|
|
||||||
allProviderModels: new Map(),
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
availableThinkingOptions: [],
|
|
||||||
isModelLoading: false,
|
|
||||||
modelError: null,
|
|
||||||
refreshProviderModels: () => undefined,
|
|
||||||
setProviderAndModelFromUser: () => undefined,
|
|
||||||
workingDirIsEmpty: false,
|
|
||||||
persistFormPreferences: async () => undefined,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let __private__: typeof import("./use-agent-input-draft").__private__;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const storage = new Map<string, string>();
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: {
|
|
||||||
localStorage: {
|
|
||||||
getItem: (key: string) => storage.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => {
|
|
||||||
storage.set(key, value);
|
|
||||||
},
|
|
||||||
removeItem: (key: string) => {
|
|
||||||
storage.delete(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
configurable: true,
|
removeItem: async (key: string) => {
|
||||||
|
map.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveDraftKey", () => {
|
||||||
|
it("returns a string draft key unchanged", () => {
|
||||||
|
expect(
|
||||||
|
resolveDraftKey({
|
||||||
|
draftKey: "draft:key",
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:key");
|
||||||
});
|
});
|
||||||
|
|
||||||
({ __private__ } = await import("./use-agent-input-draft"));
|
it("resolves a computed draft key from the selected server", () => {
|
||||||
|
expect(
|
||||||
|
resolveDraftKey({
|
||||||
|
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:host-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useAgentInputDraft", () => {
|
describe("resolveEffectiveComposerModelId", () => {
|
||||||
describe("__private__.resolveDraftKey", () => {
|
it("returns the selected model trimmed", () => {
|
||||||
it("returns an object draft key string unchanged", () => {
|
expect(
|
||||||
expect(
|
resolveEffectiveComposerModelId({
|
||||||
__private__.resolveDraftKey({
|
selectedModel: " gpt-5.4-mini ",
|
||||||
draftKey: "draft:key",
|
availableModels: [],
|
||||||
selectedServerId: "host-1",
|
}),
|
||||||
}),
|
).toBe("gpt-5.4-mini");
|
||||||
).toBe("draft:key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves a computed draft key from the selected server", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveDraftKey({
|
|
||||||
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
|
||||||
selectedServerId: "host-1",
|
|
||||||
}),
|
|
||||||
).toBe("draft:host-1");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
it("returns empty string when no model selected", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerModelId({
|
||||||
provider: "codex",
|
selectedModel: "",
|
||||||
id: "gpt-5.4",
|
availableModels: [],
|
||||||
label: "gpt-5.4",
|
}),
|
||||||
isDefault: true,
|
).toBe("");
|
||||||
},
|
});
|
||||||
{
|
});
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4-mini",
|
|
||||||
label: "gpt-5.4-mini",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("prefers the selected model when present", () => {
|
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||||
expect(
|
const models = [
|
||||||
__private__.resolveEffectiveComposerModelId({
|
{
|
||||||
selectedModel: "gpt-5.4-mini",
|
provider: "codex",
|
||||||
availableModels: models,
|
id: "gpt-5.4",
|
||||||
}),
|
label: "gpt-5.4",
|
||||||
).toBe("gpt-5.4-mini");
|
isDefault: true,
|
||||||
});
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it("returns empty string when no model selected", () => {
|
it("prefers the selected thinking option when present", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.resolveEffectiveComposerModelId({
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
selectedModel: "",
|
selectedThinkingOptionId: "medium",
|
||||||
availableModels: models,
|
availableModels: models,
|
||||||
}),
|
effectiveModelId: "gpt-5.4",
|
||||||
).toBe("");
|
}),
|
||||||
});
|
).toBe("medium");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
it("falls back to the model default thinking option", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe("high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
|
it("returns undefined when cwd is empty", () => {
|
||||||
|
expect(
|
||||||
|
buildDraftComposerCommandConfig({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
id: "gpt-5.4",
|
cwd: " ",
|
||||||
label: "gpt-5.4",
|
modeOptions: [],
|
||||||
isDefault: true,
|
selectedMode: "",
|
||||||
defaultThinkingOptionId: "high",
|
effectiveModelId: "gpt-5.4",
|
||||||
thinkingOptions: [
|
effectiveThinkingOptionId: "high",
|
||||||
{ id: "medium", label: "Medium" },
|
}),
|
||||||
{ id: "high", label: "High", isDefault: true },
|
).toBeUndefined();
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("prefers the selected thinking option when present", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
|
||||||
selectedThinkingOptionId: "medium",
|
|
||||||
availableModels: models,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
}),
|
|
||||||
).toBe("medium");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the model default thinking option", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
availableModels: models,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
}),
|
|
||||||
).toBe("high");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
it("returns undefined when cwd is empty", () => {
|
expect(
|
||||||
expect(
|
buildDraftComposerCommandConfig({
|
||||||
__private__.buildDraftComposerCommandConfig({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: " ",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "high",
|
|
||||||
}),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds the draft command config from derived composer state", () => {
|
|
||||||
expect(
|
|
||||||
__private__.buildDraftComposerCommandConfig({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: "/repo",
|
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
selectedMode: "auto",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "high",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeId: "auto",
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
model: "gpt-5.4",
|
selectedMode: "auto",
|
||||||
thinkingOptionId: "high",
|
effectiveModelId: "gpt-5.4",
|
||||||
});
|
effectiveThinkingOptionId: "high",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createAgentInputDraftCore", () => {
|
||||||
|
it("load returns null when nothing has been saved", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
expect(await core.load()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load returns the draft that was saved", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||||
|
expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear removes the persisted draft", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "hello", attachments: [], cwd: "/repo" });
|
||||||
|
await core.clear();
|
||||||
|
expect(await core.load()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] }));
|
||||||
|
|
||||||
|
const core = createAgentInputDraftCore({ storage, storageKey: "test" });
|
||||||
|
const draft = await core.load("/initial");
|
||||||
|
expect(draft?.cwd).toBe("/initial");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips attachments unchanged", async () => {
|
||||||
|
const attachment = {
|
||||||
|
kind: "github_issue" as const,
|
||||||
|
item: {
|
||||||
|
kind: "issue" as const,
|
||||||
|
number: 42,
|
||||||
|
title: "Unify attachments",
|
||||||
|
url: "https://github.com/example/repo/issues/42",
|
||||||
|
state: "open" as const,
|
||||||
|
body: "body",
|
||||||
|
labels: ["composer"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "", attachments: [attachment], cwd: "/repo" });
|
||||||
|
const draft = await core.load();
|
||||||
|
expect(draft?.attachments).toEqual([attachment]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolated keys do not share state", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const a = createAgentInputDraftCore({ storage, storageKey: "key-a" });
|
||||||
|
const b = createAgentInputDraftCore({ storage, storageKey: "key-b" });
|
||||||
|
await a.save({ text: "from a", attachments: [], cwd: "" });
|
||||||
|
expect(await b.load()).toBeNull();
|
||||||
|
expect(await a.load()).toMatchObject({ text: "from a" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import {
|
|||||||
type UseAgentFormStateResult,
|
type UseAgentFormStateResult,
|
||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
|
import {
|
||||||
|
areAttachmentsEqual,
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
buildDraftStatusControls,
|
||||||
|
hasDraftContent,
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
type DraftKeyInput,
|
||||||
|
} from "@/hooks/use-agent-input-draft-core";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
|
|||||||
lockedWorkingDir?: string;
|
lockedWorkingDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftKeyContext {
|
|
||||||
selectedServerId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
|
||||||
|
|
||||||
interface UseAgentInputDraftInput {
|
interface UseAgentInputDraftInput {
|
||||||
draftKey: DraftKeyInput;
|
draftKey: DraftKeyInput;
|
||||||
initialCwd?: string;
|
initialCwd?: string;
|
||||||
@@ -57,121 +59,6 @@ interface AgentInputDraft {
|
|||||||
composerState: DraftComposerState | null;
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasDraftContent(input: {
|
|
||||||
text: string;
|
|
||||||
attachments: UserComposerAttachment[];
|
|
||||||
cwd: string;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function areAttachmentsEqual(input: {
|
|
||||||
left: UserComposerAttachment[];
|
|
||||||
right: UserComposerAttachment[];
|
|
||||||
}): boolean {
|
|
||||||
if (input.left.length !== input.right.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.left.every((attachment, index) => {
|
|
||||||
const other = input.right[index];
|
|
||||||
return JSON.stringify(attachment) === JSON.stringify(other);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDraftKey(input: {
|
|
||||||
draftKey: DraftKeyInput;
|
|
||||||
selectedServerId: string | null;
|
|
||||||
}): string {
|
|
||||||
if (typeof input.draftKey === "function") {
|
|
||||||
return input.draftKey({ selectedServerId: input.selectedServerId });
|
|
||||||
}
|
|
||||||
return input.draftKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveComposerModelId(input: {
|
|
||||||
selectedModel: string;
|
|
||||||
availableModels: AgentModelDefinition[];
|
|
||||||
}): string {
|
|
||||||
return input.selectedModel.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveComposerThinkingOptionId(input: {
|
|
||||||
selectedThinkingOptionId: string;
|
|
||||||
availableModels: AgentModelDefinition[];
|
|
||||||
effectiveModelId: string;
|
|
||||||
}): string {
|
|
||||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
|
||||||
if (selectedThinkingOptionId) {
|
|
||||||
return selectedThinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedModelDefinition =
|
|
||||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
|
||||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftComposerCommandConfig(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
cwd: string;
|
|
||||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
|
||||||
selectedMode: string;
|
|
||||||
effectiveModelId: string;
|
|
||||||
effectiveThinkingOptionId: string;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
}): DraftCommandConfig | undefined {
|
|
||||||
const cwd = input.cwd.trim();
|
|
||||||
if (!input.provider || !cwd) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: input.provider,
|
|
||||||
cwd,
|
|
||||||
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
|
||||||
? { modeId: input.selectedMode }
|
|
||||||
: {}),
|
|
||||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
|
||||||
...(input.effectiveThinkingOptionId
|
|
||||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
|
||||||
: {}),
|
|
||||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftStatusControls(input: {
|
|
||||||
formState: UseAgentFormStateResult;
|
|
||||||
features?: DraftAgentStatusBarProps["features"];
|
|
||||||
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
|
|
||||||
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
|
|
||||||
}): DraftAgentStatusBarProps {
|
|
||||||
const { formState, features, onSetFeature, onDropdownClose } = input;
|
|
||||||
return {
|
|
||||||
providerDefinitions: formState.providerDefinitions,
|
|
||||||
selectedProvider: formState.selectedProvider,
|
|
||||||
onSelectProvider: formState.setProviderFromUser,
|
|
||||||
modeOptions: formState.modeOptions,
|
|
||||||
selectedMode: formState.selectedMode,
|
|
||||||
onSelectMode: formState.setModeFromUser,
|
|
||||||
models: formState.availableModels,
|
|
||||||
selectedModel: formState.selectedModel,
|
|
||||||
onSelectModel: formState.setModelFromUser,
|
|
||||||
isModelLoading: formState.isModelLoading,
|
|
||||||
allProviderModels: formState.allProviderModels,
|
|
||||||
isAllModelsLoading: formState.isAllModelsLoading,
|
|
||||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
|
||||||
thinkingOptions: formState.availableThinkingOptions,
|
|
||||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
|
||||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
|
||||||
features,
|
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
|
||||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||||
const composerOptions = input.composer ?? null;
|
const composerOptions = input.composer ?? null;
|
||||||
const formState = useAgentFormState({
|
const formState = useAgentFormState({
|
||||||
|
|||||||
@@ -72,33 +72,15 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert audio file URI to Blob for daemon transport
|
|
||||||
* Returns a Blob-like object that works in React Native
|
|
||||||
*/
|
|
||||||
async function uriToBlob(uri: string): Promise<Blob> {
|
async function uriToBlob(uri: string): Promise<Blob> {
|
||||||
// Use expo-file-system to read the file
|
|
||||||
const file = new File(uri);
|
const file = new File(uri);
|
||||||
const base64 = await file.base64();
|
const base64 = await file.base64();
|
||||||
const size = file.size;
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
// React Native doesn't support creating Blobs from binary data
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
// Create a Blob-like object that has the methods we need
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
const blobLike = {
|
}
|
||||||
type: "audio/m4a",
|
return new Blob([bytes], { type: "audio/m4a" });
|
||||||
size: size,
|
|
||||||
arrayBuffer: async () => {
|
|
||||||
// Convert base64 to ArrayBuffer
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
},
|
|
||||||
} as Blob;
|
|
||||||
|
|
||||||
return blobLike;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
// Monitor audio levels if metering is enabled
|
// Monitor audio levels if metering is enabled
|
||||||
// Use configRef to access the latest callback without recreating the effect
|
// Use configRef to access the latest callback without recreating the effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentConfig = configRef.current;
|
if (!configRef.current?.onAudioLevel || !recorderState.isRecording) {
|
||||||
if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const metering = recorderState.metering;
|
const metering = recorderState.metering;
|
||||||
if (metering !== undefined && metering !== null) {
|
if (metering !== undefined && metering !== null) {
|
||||||
// Normalize metering value (typically ranges from -160 to 0 dB)
|
|
||||||
// Convert to 0-1 range where 0 is silence and 1 is loud
|
|
||||||
// We'll use -40 dB as the threshold for "loud"
|
|
||||||
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
||||||
configRef.current?.onAudioLevel?.(normalized);
|
configRef.current?.onAudioLevel?.(normalized);
|
||||||
}
|
}
|
||||||
}, 100); // Check every 100ms
|
}, 100);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [recorderState.metering, recorderState.isRecording]);
|
}, [recorderState.metering, recorderState.isRecording]);
|
||||||
@@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
await recorder.prepareToRecordAsync();
|
await recorder.prepareToRecordAsync();
|
||||||
attemptGuardRef.current.assertCurrent(attemptId);
|
attemptGuardRef.current.assertCurrent(attemptId);
|
||||||
|
|
||||||
await recorder.record();
|
recorder.record();
|
||||||
attemptGuardRef.current.assertCurrent(attemptId);
|
attemptGuardRef.current.assertCurrent(attemptId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRecordingStartTime(null);
|
setRecordingStartTime(null);
|
||||||
if (error instanceof AttemptCancelledError) {
|
if (error instanceof AttemptCancelledError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((error as { message?: string })?.message !== "Recording cancelled") {
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message !== "Recording cancelled") {
|
||||||
console.error("[AudioRecorder] Failed to start recording:", error);
|
console.error("[AudioRecorder] Failed to start recording:", error);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(`Failed to start audio recording: ${message}`, { cause: error });
|
||||||
`Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRecordingStartTime(null);
|
setRecordingStartTime(null);
|
||||||
console.error("[AudioRecorder] Failed to stop recording:", error);
|
console.error("[AudioRecorder] Failed to stop recording:", error);
|
||||||
throw new Error(
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
`Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
throw new Error(`Failed to stop audio recording: ${message}`, { cause: error });
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
startStopMutexRef.current = null;
|
startStopMutexRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isRecording = useCallback(() => {
|
const isRecording = useCallback(() => {
|
||||||
return Boolean(recorderState.isRecording);
|
return recorderState.isRecording;
|
||||||
}, [recorderState.isRecording]);
|
}, [recorderState.isRecording]);
|
||||||
|
|
||||||
// Return stable object using useMemo
|
// Return stable object using useMemo
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
return () => {
|
return () => {
|
||||||
attemptGuard.cancel();
|
attemptGuard.cancel();
|
||||||
try {
|
try {
|
||||||
void mediaRecorder.current?.stop();
|
mediaRecorder.current?.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore stop during unmount.
|
// Ignore stop during unmount.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function useCommandCenter() {
|
|||||||
if (currentItems.length === 0) return;
|
if (currentItems.length === 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
||||||
handleSelectItemRef.current(currentItems[index]!);
|
handleSelectItemRef.current(currentItems[index]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
|||||||
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||||
const out = new Float32Array(input.length);
|
const out = new Float32Array(input.length);
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
out[i] = input[i]! / 32768;
|
out[i] = input[i] / 32768;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
@@ -209,8 +209,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
const decodeAudioData = useCallback(
|
const decodeAudioData = useCallback(
|
||||||
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
||||||
const maybePromise = context.decodeAudioData(buffer);
|
const maybePromise = context.decodeAudioData(buffer);
|
||||||
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
|
if (maybePromise && typeof maybePromise.then === "function") {
|
||||||
return maybePromise as Promise<AudioBuffer>;
|
return maybePromise;
|
||||||
}
|
}
|
||||||
return await new Promise<AudioBuffer>((resolve, reject) => {
|
return await new Promise<AudioBuffer>((resolve, reject) => {
|
||||||
context.decodeAudioData(buffer, resolve, reject);
|
context.decodeAudioData(buffer, resolve, reject);
|
||||||
@@ -276,7 +276,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stream = rawStream as MediaStream;
|
const stream = rawStream;
|
||||||
|
|
||||||
const context = new AudioContextCtor();
|
const context = new AudioContextCtor();
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
|
|
||||||
const recorder = new RecorderCtor(stream, {
|
const recorder = new RecorderCtor(stream, {
|
||||||
mimeType: "audio/webm;codecs=opus",
|
mimeType: "audio/webm;codecs=opus",
|
||||||
} as MediaRecorderOptions) as MediaRecorder;
|
} as MediaRecorderOptions);
|
||||||
|
|
||||||
const recorderRefs: RecorderRefs = {
|
const recorderRefs: RecorderRefs = {
|
||||||
recorder,
|
recorder,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user