mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c83dea127 | ||
|
|
96b39d4c56 | ||
|
|
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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -658,23 +658,6 @@ export const createAgentInRepo = async (
|
|||||||
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) {
|
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||||
// Wait for the stop button to disappear
|
// Wait for the stop button to disappear
|
||||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
function composerInput(page: Page) {
|
||||||
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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,52 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -265,7 +265,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 +291,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,7 +308,7 @@ 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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -32,7 +32,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 () => {
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } 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 } from "./helpers/settings";
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
function getSeededServerId(): string {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -18,13 +19,8 @@ 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) {
|
async function expectHostLabelHeader(page: Page) {
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
@@ -38,7 +34,7 @@ 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.
|
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||||
await expectHostLabelHeader(page);
|
await expectHostLabelHeader(page);
|
||||||
@@ -67,7 +63,7 @@ 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 expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -85,7 +81,7 @@ 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
|
// Pair-device now lives behind a row that only the local host sees
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
if (!workspaceResult.workspace) {
|
if (!workspaceResult.workspace) {
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
}
|
}
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
const workspaceId = workspaceResult.workspace.id;
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
@@ -95,7 +95,7 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
if (!workspaceResult.workspace) {
|
if (!workspaceResult.workspace) {
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
}
|
}
|
||||||
const workspaceId = String(workspaceResult.workspace.id);
|
const workspaceId = workspaceResult.workspace.id;
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||||
// with direct URL navigation to the 2nd+ workspace.
|
// with direct URL navigation to the 2nd+ workspace.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ 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);
|
||||||
@@ -78,7 +78,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
|
||||||
|
|||||||
@@ -337,7 +337,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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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("");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,20 +160,17 @@ function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
|||||||
function makeProviderMap(
|
function makeProviderMap(
|
||||||
...definitions: AgentProviderDefinition[]
|
...definitions: AgentProviderDefinition[]
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
|
return new Map(definitions.map((d) => [d.id, d]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ function resolveProvider(input: {
|
|||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
return initialValues.provider;
|
return initialValues.provider;
|
||||||
}
|
}
|
||||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) {
|
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||||
return preferences.provider as AgentProvider;
|
return preferences.provider;
|
||||||
}
|
}
|
||||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
|
|||||||
|
|
||||||
const matchingProviders = new Set(
|
const matchingProviders = new Set(
|
||||||
args.snapshotEntries
|
args.snapshotEntries
|
||||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
|
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||||
.map((entry) => entry.provider),
|
.map((entry) => entry.provider),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export function useKeyboardShortcuts({
|
|||||||
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
const store = useKeyboardShortcutsStore.getState();
|
||||||
if (!store.commandCenterOpen) {
|
if (!store.commandCenterOpen) {
|
||||||
const target = event.target instanceof Element ? (event.target as Element) : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
const targetEl =
|
const targetEl =
|
||||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||||
(target instanceof HTMLElement ? target : null);
|
(target instanceof HTMLElement ? target : null);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function usePrPaneData({
|
|||||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||||
const status = checkoutPrStatus.status;
|
const status = checkoutPrStatus.status;
|
||||||
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
|
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
|
||||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
|
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
|
||||||
const unsupportedKey =
|
const unsupportedKey =
|
||||||
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
||||||
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
||||||
@@ -100,7 +100,7 @@ export function usePrPaneData({
|
|||||||
timelineUnsupported,
|
timelineUnsupported,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timelineQuery = useQuery<PullRequestTimeline, Error>({
|
const timelineQuery = useQuery<PullRequestTimeline>({
|
||||||
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ describe("useProjects", () => {
|
|||||||
});
|
});
|
||||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||||
"githubUrl",
|
"githubUrl",
|
||||||
"hiddenUnsupportedRemoteCount",
|
|
||||||
"hostCount",
|
"hostCount",
|
||||||
"hosts",
|
"hosts",
|
||||||
"onlineHostCount",
|
"onlineHostCount",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface ProjectHostError {
|
|||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
hiddenUnsupportedRemoteCount: number;
|
|
||||||
hostErrors: ProjectHostError[];
|
hostErrors: ProjectHostError[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
@@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
projects: projectsQuery.data?.projects ?? [],
|
projects: projectsQuery.data?.projects ?? [],
|
||||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
|
||||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||||
isLoading: projectsQuery.isLoading,
|
isLoading: projectsQuery.isLoading,
|
||||||
isFetching: projectsQuery.isFetching,
|
isFetching: projectsQuery.isFetching,
|
||||||
|
|||||||
@@ -235,16 +235,16 @@ export async function loadSettingsFromStorage(): Promise<Settings> {
|
|||||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const result: Partial<AppSettings> = {};
|
const result: Partial<AppSettings> = {};
|
||||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||||
result.theme = stored.theme as AppSettings["theme"];
|
result.theme = stored.theme;
|
||||||
}
|
}
|
||||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||||
result.sendBehavior = stored.sendBehavior;
|
result.sendBehavior = stored.sendBehavior;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
typeof stored.serviceUrlBehavior === "string" &&
|
typeof stored.serviceUrlBehavior === "string" &&
|
||||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior as ServiceUrlBehavior)
|
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
||||||
) {
|
) {
|
||||||
result.serviceUrlBehavior = stored.serviceUrlBehavior as ServiceUrlBehavior;
|
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,7 +750,7 @@ function ChatAgentContent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isInitializing = agentId ? isInitializingFromMap !== false : false;
|
const isInitializing = agentId ? isInitializingFromMap : false;
|
||||||
const isHistorySyncing = useMemo(() => {
|
const isHistorySyncing = useMemo(() => {
|
||||||
if (!agentId || !isInitializing) {
|
if (!agentId || !isInitializing) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function polyfillCrypto(): void {
|
|||||||
if (typeof g.TextEncoder !== "function") {
|
if (typeof g.TextEncoder !== "function") {
|
||||||
class BufferTextEncoder {
|
class BufferTextEncoder {
|
||||||
encode(input = ""): Uint8Array {
|
encode(input = ""): Uint8Array {
|
||||||
return Uint8Array.from(Buffer.from(String(input), "utf8"));
|
return Uint8Array.from(Buffer.from(input, "utf8"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder;
|
g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder;
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ describe("useInlineReviewController", () => {
|
|||||||
body: "first comment",
|
body: "first comment",
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => result.current.onEditComment(reviewTarget, savedComment!));
|
act(() => result.current.onEditComment(reviewTarget, savedComment));
|
||||||
expect(result.current.editor).toEqual({
|
expect(result.current.editor).toEqual({
|
||||||
target: reviewTarget,
|
target: reviewTarget,
|
||||||
commentId: savedComment?.id,
|
commentId: savedComment?.id,
|
||||||
@@ -208,8 +208,8 @@ describe("useInlineReviewController", () => {
|
|||||||
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||||
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
||||||
|
|
||||||
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
|
act(() => result.current.onEditComment(reviewTarget, updatedComment));
|
||||||
act(() => result.current.onDeleteComment(updatedComment!.id));
|
act(() => result.current.onDeleteComment(updatedComment.id));
|
||||||
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
||||||
expect(result.current.editor).toBeNull();
|
expect(result.current.editor).toBeNull();
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const { theme, projectsState, hostState, navigate, confirmDialogMock, client } =
|
|||||||
current: {
|
current: {
|
||||||
projects: [],
|
projects: [],
|
||||||
hostErrors: [] as ProjectHostError[],
|
hostErrors: [] as ProjectHostError[],
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -442,7 +441,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
|||||||
hostCount: hosts.length,
|
hostCount: hosts.length,
|
||||||
onlineHostCount: hosts.filter((host) => host.isOnline).length,
|
onlineHostCount: hosts.filter((host) => host.isOnline).length,
|
||||||
githubUrl: "https://github.com/acme/app",
|
githubUrl: "https://github.com/acme/app",
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -451,7 +449,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
|||||||
projectsState.current = {
|
projectsState.current = {
|
||||||
projects: [],
|
projects: [],
|
||||||
hostErrors: [],
|
hostErrors: [],
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -752,7 +749,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(client.writeProjectConfig).toHaveBeenCalledTimes(1);
|
expect(client.writeProjectConfig).toHaveBeenCalledTimes(1);
|
||||||
const callArg = client.writeProjectConfig.mock.calls[0]![0] as {
|
const callArg = client.writeProjectConfig.mock.calls[0][0] as {
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
config: PaseoConfigRaw;
|
config: PaseoConfigRaw;
|
||||||
expectedRevision: PaseoConfigRevision | null;
|
expectedRevision: PaseoConfigRevision | null;
|
||||||
@@ -767,7 +764,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
|||||||
// Subsequent saves use the freshly-returned revision.
|
// Subsequent saves use the freshly-returned revision.
|
||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
const secondArg = client.writeProjectConfig.mock.calls[1]![0] as {
|
const secondArg = client.writeProjectConfig.mock.calls[1][0] as {
|
||||||
expectedRevision: PaseoConfigRevision;
|
expectedRevision: PaseoConfigRevision;
|
||||||
};
|
};
|
||||||
expect(secondArg.expectedRevision).toEqual(newRevision);
|
expect(secondArg.expectedRevision).toEqual(newRevision);
|
||||||
@@ -869,7 +866,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
// string stays string when edited in place
|
// string stays string when edited in place
|
||||||
expect(savedConfig.worktree?.setup).toBe("npm install\nnpm run prepare");
|
expect(savedConfig.worktree?.setup).toBe("npm install\nnpm run prepare");
|
||||||
});
|
});
|
||||||
@@ -897,7 +894,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
expect(savedConfig.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
|
expect(savedConfig.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -920,7 +917,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
expect(savedConfig.worktree?.setup).toBe("npm install");
|
expect(savedConfig.worktree?.setup).toBe("npm install");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -943,7 +940,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
expect(savedConfig.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
|
expect(savedConfig.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -996,7 +993,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
const scriptKeys = Object.keys((savedConfig as Record<string, unknown>).scripts ?? {});
|
const scriptKeys = Object.keys((savedConfig as Record<string, unknown>).scripts ?? {});
|
||||||
// One of the two scripts was removed.
|
// One of the two scripts was removed.
|
||||||
expect(scriptKeys.length).toBe(1);
|
expect(scriptKeys.length).toBe(1);
|
||||||
@@ -1031,7 +1028,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
|||||||
click(requireById("save-button"));
|
click(requireById("save-button"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||||
expect((savedConfig as Record<string, unknown>).customTopLevel).toBe("preserved");
|
expect((savedConfig as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||||
const worktreeRecord = (savedConfig.worktree ?? {}) as Record<string, unknown>;
|
const worktreeRecord = (savedConfig.worktree ?? {}) as Record<string, unknown>;
|
||||||
expect(worktreeRecord.customWorktreeField).toBe("keep");
|
expect(worktreeRecord.customWorktreeField).toBe("keep");
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
|||||||
current: {
|
current: {
|
||||||
projects: [],
|
projects: [],
|
||||||
hostErrors: [],
|
hostErrors: [],
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -231,7 +230,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
|||||||
hostCount: hosts.length,
|
hostCount: hosts.length,
|
||||||
onlineHostCount,
|
onlineHostCount,
|
||||||
githubUrl: "https://github.com/acme/app",
|
githubUrl: "https://github.com/acme/app",
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
|||||||
projectsState.current = {
|
projectsState.current = {
|
||||||
projects: [],
|
projects: [],
|
||||||
hostErrors: [],
|
hostErrors: [],
|
||||||
hiddenUnsupportedRemoteCount: 0,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -343,19 +340,12 @@ describe("ProjectsScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the empty state when there are no projects", () => {
|
it("renders the empty state when there are no projects", () => {
|
||||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
setProjectsState({ projects: [] });
|
||||||
|
|
||||||
render({ kind: "projects" });
|
render({ kind: "projects" });
|
||||||
|
|
||||||
expect(container?.textContent).toContain("No projects yet");
|
expect(container?.textContent).toContain("No projects yet");
|
||||||
});
|
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
|
||||||
|
|
||||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
|
||||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
|
||||||
|
|
||||||
render({ kind: "projects" });
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ProjectsScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
const { projects, hostErrors, isLoading } = useProjects();
|
||||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||||
|
|
||||||
if (isLoading && projects.length === 0) {
|
if (isLoading && projects.length === 0) {
|
||||||
@@ -27,13 +27,9 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
const message =
|
|
||||||
hiddenUnsupportedRemoteCount > 0
|
|
||||||
? "Non-GitHub remote projects aren't supported yet"
|
|
||||||
: "No projects yet";
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered} testID="projects-list">
|
<View style={styles.centered} testID="projects-list">
|
||||||
<Text style={styles.emptyText}>{message}</Text>
|
<Text style={styles.emptyText}>No projects yet</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1541,7 +1541,7 @@ function WorkspaceScreenContent({
|
|||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
return await client.getCheckoutStatus(workspaceDirectory);
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
expect(result.closeButtonPolicy).toBe("all");
|
expect(result.closeButtonPolicy).toBe("all");
|
||||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||||
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
||||||
expect(result.items.every((item) => item.showLabel === false)).toBe(true);
|
expect(result.items.every((item) => !item.showLabel)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => {
|
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => {
|
||||||
@@ -81,7 +81,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
expect(result.closeButtonPolicy).toBe("all");
|
expect(result.closeButtonPolicy).toBe("all");
|
||||||
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
||||||
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
|
||||||
expect(result.items.every((item) => item.showLabel === false)).toBe(true);
|
expect(result.items.every((item) => !item.showLabel)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty layout details when there are no tabs", () => {
|
it("returns empty layout details when there are no tabs", () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const model = deriveWorkspaceTabModel({
|
const model = deriveWorkspaceTabModel({
|
||||||
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
|
tabs: [uiTabs[0], uiTabs[2], uiTabs[1]],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ describe("checkout-git-actions-store", () => {
|
|||||||
const first = store.commit({ serverId, cwd });
|
const first = store.commit({ serverId, cwd });
|
||||||
const second = store.commit({ serverId, cwd });
|
const second = store.commit({ serverId, cwd });
|
||||||
|
|
||||||
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
|
|
||||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
|
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
|
||||||
|
|
||||||
deferred.resolve({});
|
deferred.resolve({});
|
||||||
@@ -126,8 +125,9 @@ describe("checkout-git-actions-store", () => {
|
|||||||
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
|
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
|
||||||
|
|
||||||
expect(order).toEqual(["pull", "push"]);
|
expect(order).toEqual(["pull", "push"]);
|
||||||
expect(client.checkoutPull).toHaveBeenCalledWith(cwd);
|
expect(
|
||||||
expect(client.checkoutPush).toHaveBeenCalledWith(cwd);
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||||
|
).toBe("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not push when pull fails for pull-and-push", async () => {
|
it("does not push when pull fails for pull-and-push", async () => {
|
||||||
@@ -146,7 +146,9 @@ describe("checkout-git-actions-store", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||||
).rejects.toThrow("pull conflict");
|
).rejects.toThrow("pull conflict");
|
||||||
expect(client.checkoutPush).not.toHaveBeenCalled();
|
expect(
|
||||||
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||||
|
).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces push errors from pull-and-push after a successful pull", async () => {
|
it("surfaces push errors from pull-and-push after a successful pull", async () => {
|
||||||
@@ -165,8 +167,9 @@ describe("checkout-git-actions-store", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||||
).rejects.toThrow("push rejected");
|
).rejects.toThrow("push rejected");
|
||||||
expect(client.checkoutPull).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
expect(client.checkoutPush).toHaveBeenCalledTimes(1);
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||||
|
).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
|
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
|
||||||
@@ -212,7 +215,6 @@ describe("checkout-git-actions-store", () => {
|
|||||||
.getState()
|
.getState()
|
||||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||||
|
|
||||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
|
||||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||||
{ worktreePath: "/tmp/other" },
|
{ worktreePath: "/tmp/other" },
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const asyncStorageMock = vi.hoisted(() => ({
|
const asyncStorageMock = vi.hoisted(() => {
|
||||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
const storage = new Map<string, string>();
|
||||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
return {
|
||||||
}));
|
storage,
|
||||||
|
getItem: vi.fn(async (key: string): Promise<string | null> => storage.get(key) ?? null),
|
||||||
|
setItem: vi.fn(async (key: string, value: string): Promise<void> => {
|
||||||
|
storage.set(key, value);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: asyncStorageMock,
|
default: asyncStorageMock,
|
||||||
@@ -94,10 +100,17 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
|
|||||||
describe("navigation active workspace store", () => {
|
describe("navigation active workspace store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
asyncStorageMock.storage.clear();
|
||||||
asyncStorageMock.getItem.mockReset();
|
asyncStorageMock.getItem.mockReset();
|
||||||
asyncStorageMock.setItem.mockReset();
|
asyncStorageMock.setItem.mockReset();
|
||||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
asyncStorageMock.getItem.mockImplementation(
|
||||||
asyncStorageMock.setItem.mockResolvedValue();
|
async (key: string): Promise<string | null> => asyncStorageMock.storage.get(key) ?? null,
|
||||||
|
);
|
||||||
|
asyncStorageMock.setItem.mockImplementation(
|
||||||
|
async (key: string, value: string): Promise<void> => {
|
||||||
|
asyncStorageMock.storage.set(key, value);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -222,7 +235,6 @@ describe("navigation active workspace store", () => {
|
|||||||
it("hydrates empty and corrupt workspace route storage as null", async () => {
|
it("hydrates empty and corrupt workspace route storage as null", async () => {
|
||||||
installWindowStub("/open-project");
|
installWindowStub("/open-project");
|
||||||
|
|
||||||
asyncStorageMock.getItem.mockResolvedValueOnce(null);
|
|
||||||
let store = await import("@/stores/navigation-active-workspace-store");
|
let store = await import("@/stores/navigation-active-workspace-store");
|
||||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
|
|
||||||
@@ -244,10 +256,16 @@ describe("navigation active workspace store", () => {
|
|||||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
|
|
||||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||||
|
await Promise.resolve(); // flush the fire-and-forget setItem microtask
|
||||||
|
|
||||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
vi.resetModules();
|
||||||
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
const freshStore = await import("@/stores/navigation-active-workspace-store");
|
||||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
await freshStore.hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
);
|
|
||||||
|
expect(freshStore.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
expect(freshStore.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export type MessageEntry =
|
|||||||
id: string;
|
id: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args: unknown | null;
|
args: unknown;
|
||||||
result?: unknown | null;
|
result?: unknown;
|
||||||
error?: unknown | null;
|
error?: unknown;
|
||||||
status: "executing" | "completed" | "failed";
|
status: "executing" | "completed" | "failed";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,8 +130,8 @@ export function normalizeWorkspaceDescriptor(
|
|||||||
payload: WorkspaceDescriptorPayload,
|
payload: WorkspaceDescriptorPayload,
|
||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
return {
|
return {
|
||||||
id: normalizeWorkspaceOpaqueId(String(payload.id)) ?? String(payload.id),
|
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||||
projectId: String(payload.projectId),
|
projectId: payload.projectId,
|
||||||
projectDisplayName: payload.projectDisplayName,
|
projectDisplayName: payload.projectDisplayName,
|
||||||
projectRootPath: payload.projectRootPath,
|
projectRootPath: payload.projectRootPath,
|
||||||
workspaceDirectory: payload.workspaceDirectory,
|
workspaceDirectory: payload.workspaceDirectory,
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ function findNearestSiblingPaneId(root: SplitNodeInternal, paneId: string): stri
|
|||||||
|
|
||||||
for (let depth = path.length - 1; depth >= 0; depth -= 1) {
|
for (let depth = path.length - 1; depth >= 0; depth -= 1) {
|
||||||
const parentPath = path.slice(0, depth);
|
const parentPath = path.slice(0, depth);
|
||||||
const childIndex = path[depth]!;
|
const childIndex = path[depth];
|
||||||
const parentNode = getNodeAtPath(root, parentPath);
|
const parentNode = getNodeAtPath(root, parentPath);
|
||||||
invariant(parentNode.kind === "group", "Expected parent group for pane lookup");
|
invariant(parentNode.kind === "group", "Expected parent group for pane lookup");
|
||||||
|
|
||||||
@@ -667,7 +667,7 @@ function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parentPath = path.slice(0, -1);
|
const parentPath = path.slice(0, -1);
|
||||||
const removeIndex = path[path.length - 1]!;
|
const removeIndex = path[path.length - 1];
|
||||||
const parentNode = getNodeAtPath(root, parentPath);
|
const parentNode = getNodeAtPath(root, parentPath);
|
||||||
invariant(parentNode.kind === "group", "Expected parent group while removing pane");
|
invariant(parentNode.kind === "group", "Expected parent group while removing pane");
|
||||||
|
|
||||||
@@ -676,7 +676,7 @@ function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInt
|
|||||||
|
|
||||||
const nextParentNode =
|
const nextParentNode =
|
||||||
nextParentChildren.length === 1
|
nextParentChildren.length === 1
|
||||||
? nextParentChildren[0]!
|
? nextParentChildren[0]
|
||||||
: createGroupNode({
|
: createGroupNode({
|
||||||
id: parentNode.group.id,
|
id: parentNode.group.id,
|
||||||
direction: parentNode.group.direction,
|
direction: parentNode.group.direction,
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe("workspace-layout-store tree transforms", () => {
|
|||||||
|
|
||||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||||
const nextGroup = expectGroup(nextRoot);
|
const nextGroup = expectGroup(nextRoot);
|
||||||
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
|
const nestedGroup = expectGroup(nextGroup.group.children[1]);
|
||||||
|
|
||||||
expect(nextGroup.group.direction).toBe("horizontal");
|
expect(nextGroup.group.direction).toBe("horizontal");
|
||||||
expect(nextGroup.group.children).toHaveLength(2);
|
expect(nextGroup.group.children).toHaveLength(2);
|
||||||
@@ -259,7 +259,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "/repo/worktree/b.ts",
|
path: "/repo/worktree/b.ts",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||||
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||||
@@ -280,7 +280,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "setup",
|
kind: "setup",
|
||||||
workspaceId: "ws-main",
|
workspaceId: "ws-main",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
const pane = findPaneById(layout.root, "main")!;
|
const pane = findPaneById(layout.root, "main")!;
|
||||||
|
|
||||||
expect(agentTabId).toBe("agent_agent-1");
|
expect(agentTabId).toBe("agent_agent-1");
|
||||||
@@ -306,7 +306,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "/repo/worktree/a.ts",
|
path: "/repo/worktree/a.ts",
|
||||||
});
|
});
|
||||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
const pane = findPaneById(layoutAfter.root, "main")!;
|
const pane = findPaneById(layoutAfter.root, "main")!;
|
||||||
|
|
||||||
expect(duplicateTabId).toBe(firstTabId);
|
expect(duplicateTabId).toBe(firstTabId);
|
||||||
@@ -380,7 +380,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
|
|
||||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(firstTabId).toBe("draft-1");
|
expect(firstTabId).toBe("draft-1");
|
||||||
expect(secondTabId).toBe("draft-2");
|
expect(secondTabId).toBe("draft-2");
|
||||||
@@ -416,14 +416,14 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "draft",
|
kind: "draft",
|
||||||
draftId: "draft-split",
|
draftId: "draft-split",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||||
expect(draftTabId).toBe("draft-split");
|
expect(draftTabId).toBe("draft-split");
|
||||||
expect(layout.focusedPaneId).toBe(newPaneId);
|
expect(layout.focusedPaneId).toBe(newPaneId);
|
||||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
||||||
expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([draftTabId!]);
|
expect(findPaneById(layout.root, newPaneId)?.tabIds).toEqual([draftTabId!]);
|
||||||
expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(draftTabId);
|
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||||
@@ -448,14 +448,14 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.focusTab(workspaceKey, fileTabId!);
|
store.focusTab(workspaceKey, fileTabId!);
|
||||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout.focusedPaneId).toBe("main");
|
expect(layout.focusedPaneId).toBe("main");
|
||||||
|
|
||||||
store.focusTab(workspaceKey, terminalTabId!);
|
store.focusTab(workspaceKey, terminalTabId!);
|
||||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||||
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
|
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||||
@@ -474,8 +474,8 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
const splitPane = findPaneById(layout.root, splitPaneId);
|
||||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||||
|
|
||||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||||
@@ -501,7 +501,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "/repo/worktree/retargeted.ts",
|
path: "/repo/worktree/retargeted.ts",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(draftTabId).toBe("draft-retarget");
|
expect(draftTabId).toBe("draft-retarget");
|
||||||
expect(nextTabId).toBe(draftTabId);
|
expect(nextTabId).toBe(draftTabId);
|
||||||
@@ -541,7 +541,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "/repo/worktree/existing.ts",
|
path: "/repo/worktree/existing.ts",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||||
expect(draftTabId).toBe("draft-dup");
|
expect(draftTabId).toBe("draft-dup");
|
||||||
@@ -576,7 +576,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "agent",
|
kind: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(firstAgentTabId).toBe(firstDraftTabId);
|
expect(firstAgentTabId).toBe(firstDraftTabId);
|
||||||
expect(nextTabId).toBe(firstDraftTabId);
|
expect(nextTabId).toBe(firstDraftTabId);
|
||||||
@@ -608,7 +608,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(findPaneById(layout.root, "main")).toEqual({
|
expect(findPaneById(layout.root, "main")).toEqual({
|
||||||
id: "main",
|
id: "main",
|
||||||
@@ -663,11 +663,11 @@ describe("workspace-layout-store actions", () => {
|
|||||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||||
store.focusPane(workspaceKey, "main");
|
store.focusPane(workspaceKey, "main");
|
||||||
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
||||||
expect(layout.focusedPaneId).toBe("main");
|
expect(layout.focusedPaneId).toBe("main");
|
||||||
expect(findPaneById(layout.root, splitPaneId!)).toEqual({
|
expect(findPaneById(layout.root, splitPaneId)).toEqual({
|
||||||
id: splitPaneId,
|
id: splitPaneId,
|
||||||
tabIds: [fourthTabId!, thirdTabId!],
|
tabIds: [fourthTabId!, thirdTabId!],
|
||||||
focusedTabId: fourthTabId,
|
focusedTabId: fourthTabId,
|
||||||
@@ -705,7 +705,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.focusPane(workspaceKey, "main");
|
store.focusPane(workspaceKey, "main");
|
||||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout.focusedPaneId).toBe("main");
|
expect(layout.focusedPaneId).toBe("main");
|
||||||
|
|
||||||
store.focusPane(workspaceKey, splitPaneId!);
|
store.focusPane(workspaceKey, splitPaneId!);
|
||||||
@@ -734,7 +734,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.closeTab(workspaceKey, secondTabId!);
|
store.closeTab(workspaceKey, secondTabId!);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||||
expect(layout.focusedPaneId).toBe("main");
|
expect(layout.focusedPaneId).toBe("main");
|
||||||
@@ -782,7 +782,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
position: "bottom",
|
position: "bottom",
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||||
@@ -812,11 +812,11 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
||||||
expect(findPaneById(layout.root, splitPaneId!)?.tabIds).toEqual([
|
expect(findPaneById(layout.root, splitPaneId)?.tabIds).toEqual([
|
||||||
"file_/repo/worktree/b.ts",
|
"file_/repo/worktree/b.ts",
|
||||||
"file_/repo/worktree/a.ts",
|
"file_/repo/worktree/a.ts",
|
||||||
]);
|
]);
|
||||||
@@ -852,7 +852,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.closeTab(workspaceKey, secondTabId!);
|
store.closeTab(workspaceKey, secondTabId!);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
const rootGroup = expectGroup(layout.root);
|
const rootGroup = expectGroup(layout.root);
|
||||||
|
|
||||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||||
@@ -910,7 +910,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "/repo/worktree/b.ts",
|
path: "/repo/worktree/b.ts",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
||||||
expect(duplicateTabId).toBe(secondTabId);
|
expect(duplicateTabId).toBe(secondTabId);
|
||||||
@@ -946,14 +946,14 @@ describe("workspace-layout-store actions", () => {
|
|||||||
position: "right",
|
position: "right",
|
||||||
});
|
});
|
||||||
|
|
||||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||||
const splitGroup = expectGroup(splitRoot);
|
const splitGroup = expectGroup(splitRoot);
|
||||||
const nestedGroup = expectGroup(splitGroup.group.children[1]!);
|
const nestedGroup = expectGroup(splitGroup.group.children[1]);
|
||||||
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||||
|
|
||||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||||
const resizedGroup = expectGroup(resizedRoot);
|
const resizedGroup = expectGroup(resizedRoot);
|
||||||
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]!);
|
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
|
||||||
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||||
|
|
||||||
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||||
@@ -969,7 +969,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
|
|
||||||
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||||
store.closeTab(workspaceKey, tabId!);
|
store.closeTab(workspaceKey, tabId!);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(layout).toEqual(createDefaultLayout());
|
expect(layout).toEqual(createDefaultLayout());
|
||||||
});
|
});
|
||||||
@@ -1065,7 +1065,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||||
expect(nextTabId).toBe("agent_agent-1");
|
expect(nextTabId).toBe("agent_agent-1");
|
||||||
@@ -1129,7 +1129,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
hasActivePendingDraftCreate: false,
|
hasActivePendingDraftCreate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
const tabs = collectAllTabs(layout.root);
|
const tabs = collectAllTabs(layout.root);
|
||||||
|
|
||||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||||
@@ -1189,7 +1189,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
||||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
|||||||
return `${serverId}:${workspaceId}`;
|
return `${serverId}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return isPlainRecord(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTabOrder(list: unknown): string[] {
|
function normalizeTabOrder(list: unknown): string[] {
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -94,17 +102,6 @@ function buildNextTabsForEnsure(args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyPersistedWorkspaceTabsState {
|
|
||||||
version?: number;
|
|
||||||
state?: unknown;
|
|
||||||
openTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
|
||||||
uiTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
|
||||||
focusedTabIdByWorkspace?: Record<string, string>;
|
|
||||||
tabOrderByWorkspace?: Record<string, string[]>;
|
|
||||||
lastFocusedTabByWorkspace?: Record<string, unknown>;
|
|
||||||
tabOrderLegacyByWorkspace?: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationRawSources {
|
interface MigrationRawSources {
|
||||||
rawUiTabsByWorkspace: Record<string, unknown>;
|
rawUiTabsByWorkspace: Record<string, unknown>;
|
||||||
rawFocused: Record<string, unknown>;
|
rawFocused: Record<string, unknown>;
|
||||||
@@ -113,42 +110,69 @@ interface MigrationRawSources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
||||||
const legacy = persistedState as LegacyPersistedWorkspaceTabsState | undefined;
|
const top = toObjectRecord(persistedState) ?? {};
|
||||||
const rawState = ((legacy as { state?: Record<string, unknown> } | undefined)?.state ??
|
const rawState = toObjectRecord(top.state) ?? top;
|
||||||
legacy ??
|
|
||||||
{}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawUiTabsByWorkspace: (rawState.uiTabsByWorkspace ??
|
rawUiTabsByWorkspace:
|
||||||
rawState.openTabsByWorkspace ??
|
toObjectRecord(
|
||||||
legacy?.uiTabsByWorkspace ??
|
rawState.uiTabsByWorkspace ??
|
||||||
legacy?.openTabsByWorkspace ??
|
rawState.openTabsByWorkspace ??
|
||||||
{}) as Record<string, unknown>,
|
top.uiTabsByWorkspace ??
|
||||||
rawFocused: (rawState.focusedTabIdByWorkspace ??
|
top.openTabsByWorkspace,
|
||||||
legacy?.focusedTabIdByWorkspace ??
|
) ?? {},
|
||||||
rawState.lastFocusedTabByWorkspace ??
|
rawFocused:
|
||||||
{}) as Record<string, unknown>,
|
toObjectRecord(
|
||||||
rawOrder: (rawState.tabOrderByWorkspace ??
|
rawState.focusedTabIdByWorkspace ??
|
||||||
legacy?.tabOrderByWorkspace ??
|
rawState.lastFocusedTabByWorkspace ??
|
||||||
rawState.tabOrderByWorkspace ??
|
top.focusedTabIdByWorkspace,
|
||||||
{}) as Record<string, unknown>,
|
) ?? {},
|
||||||
legacyOrder: (rawState.tabOrderByWorkspace ??
|
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
|
||||||
rawState.tabOrderLegacyByWorkspace ??
|
legacyOrder:
|
||||||
{}) as Record<string, unknown>,
|
toObjectRecord(
|
||||||
|
rawState.tabOrderByWorkspace ??
|
||||||
|
rawState.tabOrderLegacyByWorkspace ??
|
||||||
|
top.tabOrderLegacyByWorkspace,
|
||||||
|
) ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||||
|
const kind = typeof raw.kind === "string" ? raw.kind : null;
|
||||||
|
if (kind === "draft" && typeof raw.draftId === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "draft", draftId: raw.draftId });
|
||||||
|
}
|
||||||
|
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||||
|
}
|
||||||
|
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||||
|
}
|
||||||
|
if (kind === "browser" && typeof raw.browserId === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
||||||
|
}
|
||||||
|
if (kind === "file" && typeof raw.path === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "file", path: raw.path });
|
||||||
|
}
|
||||||
|
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||||
|
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function migrateSingleTab(rawTab: unknown): WorkspaceTab | null {
|
function migrateSingleTab(rawTab: unknown): WorkspaceTab | null {
|
||||||
if (!rawTab || typeof rawTab !== "object") {
|
const record = toObjectRecord(rawTab);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
const rawTarget = toObjectRecord(record.target);
|
||||||
|
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
|
||||||
if (!normalizedTarget) {
|
if (!normalizedTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
|
||||||
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||||
const rawCreatedAt = (rawTab as WorkspaceTab).createdAt;
|
const rawCreatedAt = record.createdAt;
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
target: normalizedTarget,
|
target: normalizedTarget,
|
||||||
|
|||||||
@@ -134,9 +134,8 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
return [...existing, profile];
|
return [...existing, profile];
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedProfiles = matchingIndexes.map((index) => existing[index]!);
|
const matchedProfiles = matchingIndexes.map((index) => existing[index]);
|
||||||
const prev =
|
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
||||||
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!;
|
|
||||||
const nextConnections = dedupeHostConnections([
|
const nextConnections = dedupeHostConnections([
|
||||||
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||||
input.connection,
|
input.connection,
|
||||||
@@ -180,7 +179,7 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstIndex = matchingIndexes[0]!;
|
const firstIndex = matchingIndexes[0];
|
||||||
const matchingIndexSet = new Set(matchingIndexes);
|
const matchingIndexSet = new Set(matchingIndexes);
|
||||||
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index));
|
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index));
|
||||||
next.splice(firstIndex, 0, nextProfile);
|
next.splice(firstIndex, 0, nextProfile);
|
||||||
@@ -231,16 +230,24 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return isPlainRecord(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||||
if (!connection || typeof connection !== "object") {
|
const record = toObjectRecord(connection);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const record = connection as Record<string, unknown>;
|
|
||||||
const type = typeof record.type === "string" ? record.type : null;
|
const type = typeof record.type === "string" ? record.type : null;
|
||||||
if (type === "directTcp") {
|
if (type === "directTcp") {
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeLoopbackToLocalhost(
|
const endpoint = normalizeLoopbackToLocalhost(
|
||||||
normalizeHostPort(String(record.endpoint ?? "")),
|
normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""),
|
||||||
);
|
);
|
||||||
return DirectTcpHostConnectionSchema.parse({
|
return DirectTcpHostConnectionSchema.parse({
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
@@ -254,17 +261,21 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === "directSocket") {
|
if (type === "directSocket") {
|
||||||
const path = String(record.path ?? "").trim();
|
const path = (typeof record.path === "string" ? record.path : "").trim();
|
||||||
return path ? { id: `socket:${path}`, type: "directSocket", path } : null;
|
return path ? { id: `socket:${path}`, type: "directSocket", path } : null;
|
||||||
}
|
}
|
||||||
if (type === "directPipe") {
|
if (type === "directPipe") {
|
||||||
const path = String(record.path ?? "").trim();
|
const path = (typeof record.path === "string" ? record.path : "").trim();
|
||||||
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
|
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
|
||||||
}
|
}
|
||||||
if (type === "relay") {
|
if (type === "relay") {
|
||||||
try {
|
try {
|
||||||
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ""));
|
const relayEndpoint = normalizeHostPort(
|
||||||
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? "").trim();
|
typeof record.relayEndpoint === "string" ? record.relayEndpoint : "",
|
||||||
|
);
|
||||||
|
const daemonPublicKeyB64 = (
|
||||||
|
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
|
||||||
|
).trim();
|
||||||
if (!daemonPublicKeyB64) return null;
|
if (!daemonPublicKeyB64) return null;
|
||||||
return {
|
return {
|
||||||
id: `relay:${relayEndpoint}`,
|
id: `relay:${relayEndpoint}`,
|
||||||
@@ -281,10 +292,10 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||||
if (!entry || typeof entry !== "object") {
|
const record = toObjectRecord(entry);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface AgentToolCallData {
|
|||||||
callId: string;
|
callId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: AgentToolCallStatus;
|
status: AgentToolCallStatus;
|
||||||
error: unknown | null;
|
error: unknown;
|
||||||
detail: ToolCallDetail;
|
detail: ToolCallDetail;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ function hasNonEmptyObject(value: unknown): boolean {
|
|||||||
return isRecord(value) && Object.keys(value).length > 0;
|
return isRecord(value) && Object.keys(value).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeUnknownValue(existing: unknown | null, incoming: unknown | null): unknown | null {
|
function mergeUnknownValue(existing: unknown, incoming: unknown): unknown {
|
||||||
if (incoming === null) {
|
if (incoming === null) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@@ -406,7 +406,7 @@ export function mergeToolCallDetail(
|
|||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputFromUnknownDetail(detail: ToolCallDetail): unknown | null {
|
function inputFromUnknownDetail(detail: ToolCallDetail): unknown {
|
||||||
return detail.type === "unknown" ? detail.input : null;
|
return detail.type === "unknown" ? detail.input : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ function appendTodoList(
|
|||||||
): StreamItem[] {
|
): StreamItem[] {
|
||||||
const normalizedItems = items.map((item) => ({
|
const normalizedItems = items.map((item) => ({
|
||||||
text: item.text,
|
text: item.text,
|
||||||
completed: Boolean(item.completed),
|
completed: item.completed,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lastItem = state[state.length - 1];
|
const lastItem = state[state.length - 1];
|
||||||
@@ -543,10 +543,6 @@ function appendTodoList(
|
|||||||
return [...state, entry];
|
return [...state, entry];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatErrorMessage(message: string): string {
|
|
||||||
return `Agent error\n${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reduceTimelineToolCall(
|
function reduceTimelineToolCall(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||||
@@ -662,7 +658,7 @@ function reduceTimelineEvent(
|
|||||||
}
|
}
|
||||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||||
text: todo.text,
|
text: todo.text,
|
||||||
completed: Boolean(todo.completed),
|
completed: todo.completed,
|
||||||
}));
|
}));
|
||||||
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
||||||
}
|
}
|
||||||
@@ -672,7 +668,7 @@ function reduceTimelineEvent(
|
|||||||
id: createTimelineId("error", item.message ?? "", timestamp),
|
id: createTimelineId("error", item.message ?? "", timestamp),
|
||||||
timestamp,
|
timestamp,
|
||||||
activityType: "error",
|
activityType: "error",
|
||||||
message: formatErrorMessage(item.message ?? "Unknown error"),
|
message: item.message ?? "Unknown error",
|
||||||
};
|
};
|
||||||
return finalizeActiveThoughts(appendActivityLog(state, activity));
|
return finalizeActiveThoughts(appendActivityLog(state, activity));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FetchAgentHistoryEntry, FetchAgentsEntry } from "@server/client/daemon-client";
|
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||||
|
|
||||||
type AgentDirectoryFetchEntry = FetchAgentsEntry | FetchAgentHistoryEntry;
|
type AgentDirectoryFetchEntry = FetchAgentsEntry;
|
||||||
|
|
||||||
interface PendingPermissionEntry {
|
interface PendingPermissionEntry {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
serverId,
|
serverId,
|
||||||
id: snapshot.id,
|
id: snapshot.id,
|
||||||
provider: snapshot.provider,
|
provider: snapshot.provider,
|
||||||
status: snapshot.status as AgentLifecycleStatus,
|
status: snapshot.status,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
lastUserMessageAt,
|
lastUserMessageAt,
|
||||||
|
|||||||
@@ -285,18 +285,12 @@ describe("highlightDiffFile", () => {
|
|||||||
|
|
||||||
// First content line: "const foo = 1;"
|
// First content line: "const foo = 1;"
|
||||||
const constLine = hunk.lines[1];
|
const constLine = hunk.lines[1];
|
||||||
expect(constLine.tokens).toBeDefined();
|
expect(constLine.tokens).toContainEqual(
|
||||||
expect(constLine.tokens!.length).toBeGreaterThan(0);
|
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||||
|
);
|
||||||
// Should have a "keyword" token for "const"
|
expect(constLine.tokens).toContainEqual(
|
||||||
const constToken = constLine.tokens!.find((t) => t.text === "const");
|
expect.objectContaining({ text: "1", style: "number" }),
|
||||||
expect(constToken).toBeDefined();
|
);
|
||||||
expect(constToken!.style).toBe("keyword");
|
|
||||||
|
|
||||||
// Should have a "number" token for "1"
|
|
||||||
const numberToken = constLine.tokens!.find((t) => t.text === "1");
|
|
||||||
expect(numberToken).toBeDefined();
|
|
||||||
expect(numberToken!.style).toBe("number");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights both added and removed lines correctly", () => {
|
it("highlights both added and removed lines correctly", () => {
|
||||||
@@ -306,15 +300,15 @@ describe("highlightDiffFile", () => {
|
|||||||
|
|
||||||
// Removed line: "const bar = 2;"
|
// Removed line: "const bar = 2;"
|
||||||
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
||||||
expect(removedLine?.tokens).toBeDefined();
|
expect(removedLine?.tokens).toContainEqual(
|
||||||
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
expect(removedNumber?.style).toBe("number");
|
);
|
||||||
|
|
||||||
// Added line: "const bar = 3;"
|
// Added line: "const bar = 3;"
|
||||||
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
|
expect.objectContaining({ text: "3", style: "number" }),
|
||||||
expect(addedNumber?.style).toBe("number");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not modify unsupported file types", () => {
|
it("does not modify unsupported file types", () => {
|
||||||
@@ -351,15 +345,16 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const fnLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens).toContainEqual(
|
||||||
expect(fnLine.tokens).toBeDefined();
|
expect.objectContaining({ text: "fn", style: "keyword" }),
|
||||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
);
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to C code", () => {
|
it("adds syntax highlighting tokens to C code", () => {
|
||||||
@@ -367,15 +362,14 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const mainLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens?.length).toBeGreaterThan(0);
|
||||||
expect(mainLine.tokens).toBeDefined();
|
|
||||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Java code", () => {
|
it("adds syntax highlighting tokens to Java code", () => {
|
||||||
@@ -383,15 +377,16 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const classLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens).toContainEqual(
|
||||||
expect(classLine.tokens).toBeDefined();
|
expect.objectContaining({ text: "public", style: "keyword" }),
|
||||||
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
|
);
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||||
@@ -402,8 +397,9 @@ describe("highlightDiffFile", () => {
|
|||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Go code", () => {
|
it("adds syntax highlighting tokens to Go code", () => {
|
||||||
@@ -413,8 +409,9 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to PHP code", () => {
|
it("adds syntax highlighting tokens to PHP code", () => {
|
||||||
@@ -424,8 +421,9 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("$version"),
|
(line) => line.type === "add" && line.content.includes("$version"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to YAML code", () => {
|
it("adds syntax highlighting tokens to YAML code", () => {
|
||||||
@@ -435,8 +433,7 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("count"),
|
(line) => line.type === "add" && line.content.includes("count"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens?.length).toBeGreaterThan(0);
|
||||||
expect(addedLine!.tokens!.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to XML code", () => {
|
it("adds syntax highlighting tokens to XML code", () => {
|
||||||
@@ -446,8 +443,7 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("<count>"),
|
(line) => line.type === "add" && line.content.includes("<count>"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(expect.objectContaining({ style: "tag" }));
|
||||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,7 +452,9 @@ describe("parseAndHighlightDiff", () => {
|
|||||||
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
||||||
|
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0].hunks[0].lines[1].tokens).toBeDefined();
|
expect(files[0].hunks[0].lines[1].tokens).toContainEqual(
|
||||||
|
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multiple files", () => {
|
it("handles multiple files", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
|
export type ShortcutKey = string;
|
||||||
|
|
||||||
export type ShortcutOs = "mac" | "non-mac";
|
export type ShortcutOs = "mac" | "non-mac";
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ describe("sendOsNotification", () => {
|
|||||||
clicked.clickListeners[0]?.({} as Event);
|
clicked.clickListeners[0]?.({} as Event);
|
||||||
|
|
||||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||||
const event = dispatchEvent.mock.calls[0]?.[0] as unknown as {
|
const event = dispatchEvent.mock.calls[0]?.[0] as {
|
||||||
type?: string;
|
type?: string;
|
||||||
detail?: { data?: Record<string, unknown> };
|
detail?: { data?: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
|
|||||||
cancelable: true,
|
cancelable: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return dispatch(event) === false;
|
return !dispatch(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { deriveAvatarColor, formatAge, mapPrPaneData } from "./pr-pane-data";
|
import {
|
||||||
|
deriveAvatarColor,
|
||||||
|
formatAge,
|
||||||
|
getActivityVerb,
|
||||||
|
getStateLabel,
|
||||||
|
mapPrPaneData,
|
||||||
|
} from "./pr-pane-data";
|
||||||
|
|
||||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||||
@@ -335,3 +341,38 @@ describe("formatAge", () => {
|
|||||||
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStateLabel", () => {
|
||||||
|
it.each([
|
||||||
|
["open", "Open"],
|
||||||
|
["draft", "Draft"],
|
||||||
|
["merged", "Merged"],
|
||||||
|
["closed", "Closed"],
|
||||||
|
] as const)("maps %s → %s", (state, expected) => {
|
||||||
|
expect(getStateLabel(state)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getActivityVerb", () => {
|
||||||
|
it("returns Commented for comment kind", () => {
|
||||||
|
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Approved for approved review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Requested changes for changes_requested review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||||
|
"Requested changes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed when reviewState is undefined", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -225,3 +225,17 @@ function hashLogin(login: string): number {
|
|||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStateLabel(state: PrState): string {
|
||||||
|
if (state === "draft") return "Draft";
|
||||||
|
if (state === "merged") return "Merged";
|
||||||
|
if (state === "closed") return "Closed";
|
||||||
|
return "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||||
|
if (item.kind === "comment") return "Commented";
|
||||||
|
if (item.reviewState === "approved") return "Approved";
|
||||||
|
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||||
|
return "Reviewed";
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user