mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare 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
|
||||
|
||||
cli-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -276,3 +281,5 @@ jobs:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_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",
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
@@ -53,6 +56,7 @@
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# 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
|
||||
# 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",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -10376,6 +10377,90 @@
|
||||
"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": {
|
||||
"version": "1.61.0",
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
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 { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
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
|
||||
const test = base.extend({
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `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) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +15,99 @@ const test = base.extend({
|
||||
}
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
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. " +
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
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. " +
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
// 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." });
|
||||
});
|
||||
|
||||
// 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));
|
||||
const entries: string[] = [];
|
||||
|
||||
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");
|
||||
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 },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const entries = consoleEntries.get(page);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (testInfo.status === testInfo.expectedStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
{ auto: true },
|
||||
],
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
await handle.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
@@ -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) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
probe.writeCount += 1;
|
||||
const preview = text
|
||||
.replaceAll("\u001b", "\\x1b")
|
||||
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const count = Math.min(from.length, to.length);
|
||||
const values: number[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const keydownToInputFrame = this.keydowns
|
||||
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.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
|
||||
.map((input) => {
|
||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
const event: XtermWriteEvent = {
|
||||
at: performance.now(),
|
||||
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}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export async function findWorktreeWorkspaceForProject(
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -308,7 +308,7 @@ export async function fetchWorkspaceById(
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
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) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,35 @@ interface TempRepo {
|
||||
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 (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
originUrl?: string;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
|
||||
@@ -32,7 +32,7 @@ test.beforeAll(async () => {
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
gitlabRemoteProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
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 repo.cleanup();
|
||||
},
|
||||
@@ -119,4 +136,16 @@ test.describe("Projects settings", () => {
|
||||
await saveProjectConfig(page);
|
||||
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 { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -18,13 +19,8 @@ function getSeededDaemonPort(): string {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, serverId: string) {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
@@ -38,7 +34,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
@@ -67,7 +63,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
@@ -85,7 +81,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// 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}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ async function measureDaemonBurstEcho(
|
||||
timeoutMs: STRESS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index] - sentAt);
|
||||
return {
|
||||
inputTextLength: inputText.length,
|
||||
inputFrameCount: sendTimes.length,
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
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
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -95,7 +95,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
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
|
||||
// with direct URL navigation to the 2nd+ workspace.
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
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 { test } from "./fixtures";
|
||||
import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
} 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", () => {
|
||||
// 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("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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||
});
|
||||
const workspaceId = String(workspace.id);
|
||||
const workspaceId = workspace.id;
|
||||
|
||||
const wsInfo = await findWorktreeWorkspaceForProject(client, 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");
|
||||
}
|
||||
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
|
||||
// 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");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
const workspaceId = result.workspace.id;
|
||||
|
||||
await completed;
|
||||
|
||||
|
||||
@@ -16,11 +16,34 @@ platform :ios do
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
wait_for_build_processing_to_be_complete(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
build_number: build_number.to_s,
|
||||
require "spaceship"
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
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(
|
||||
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 currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
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]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
@@ -785,7 +785,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
|
||||
@@ -22,9 +22,9 @@ class FakeRequest<T = unknown> {
|
||||
class FakeObjectStore {
|
||||
constructor(private readonly onPut: (record: unknown) => void) {}
|
||||
|
||||
put(record: unknown): FakeRequest<unknown> {
|
||||
put(record: unknown): FakeRequest {
|
||||
this.onPut(record);
|
||||
const request = new FakeRequest<unknown>();
|
||||
const request = new FakeRequest();
|
||||
queueMicrotask(() => request.emit("success"));
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function runTx<T>(
|
||||
const request = run(store);
|
||||
|
||||
request.addEventListener("success", () => {
|
||||
resolve(request.result as T);
|
||||
resolve(request.result);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
|
||||
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider as AgentProvider,
|
||||
provider: provider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
|
||||
@@ -461,7 +461,7 @@ export function BrowserPane({
|
||||
};
|
||||
const handleFaviconUpdated = (event: Event) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -63,8 +63,8 @@ describe("combined model selector helpers", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
|
||||
@@ -622,7 +622,7 @@ export function CombinedModelSelector({
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
const providers = Array.from(allProviderModels.keys());
|
||||
if (providers.length !== 1) return null;
|
||||
const providerId = providers[0]!;
|
||||
const providerId = providers[0];
|
||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [allProviderModels, providerDefinitions]);
|
||||
@@ -655,7 +655,7 @@ export function CombinedModelSelector({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
onSelect(provider, modelId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
|
||||
@@ -160,20 +160,17 @@ function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index:
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled =
|
||||
!isConnected || isCancellingAgent ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.cancelButton as object, disabled].filter((value): value is object =>
|
||||
Boolean(value),
|
||||
);
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
}
|
||||
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? (styles.iconButtonHovered as object) : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.realtimeVoiceButton as object, hoveredStyle, disabledStyle].filter(
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ function TreeRowItem({
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void onCopyPath(entry.path);
|
||||
onCopyPath(entry.path);
|
||||
}, [onCopyPath, entry.path]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
|
||||
@@ -159,7 +159,9 @@ interface MarkdownWithStableRendererProps {
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
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 ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
@@ -428,6 +430,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +638,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
useEffect(() => {
|
||||
if (cachedMetadata) {
|
||||
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
setLoadState({ status: "loading" });
|
||||
@@ -924,11 +928,11 @@ function getInlineCodeAutoLinkUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = markdownParser.linkify.match(trimmed) as Array<{
|
||||
const matches: Array<{
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
url: string;
|
||||
}> | null;
|
||||
}> | null = markdownParser.linkify.match(trimmed);
|
||||
if (!matches || matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -950,7 +954,7 @@ function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
"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}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, {
|
||||
style: [
|
||||
(child.props as { style?: StyleProp<TextStyle> }).style,
|
||||
{ color: styles.link.color as string | undefined },
|
||||
],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>)
|
||||
: child,
|
||||
)}
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||||
return cloneElement(child, {
|
||||
style: [childProps.style, { color: styles.link.color }],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||
})}
|
||||
</MarkdownLink>
|
||||
),
|
||||
image: (
|
||||
@@ -1740,8 +1741,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
@@ -1769,7 +1770,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
return (
|
||||
<View testID="speak-message" style={containerStyle}>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
@@ -1810,9 +1811,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
successBg: {
|
||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||
},
|
||||
errorBg: {
|
||||
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
||||
},
|
||||
errorBg: {},
|
||||
artifactBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||
},
|
||||
@@ -1827,6 +1826,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
iconContainer: {
|
||||
flexShrink: 0,
|
||||
height: 20,
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
@@ -1936,7 +1937,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
<IconComponent size={16} color={config.color} />
|
||||
</View>
|
||||
<View style={activityLogStylesheet.textContainer}>
|
||||
<Text style={messageTextStyle}>{displayMessage}</Text>
|
||||
<Text style={messageTextStyle} selectable>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
@@ -2419,12 +2422,13 @@ function useDetailWheelPropagationBlocker(input: {
|
||||
const { detailWrapperRef, enabled } = input;
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
const rawRef: unknown = detailWrapperRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const node = rawRef;
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
@@ -2594,7 +2598,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
enabled: !isNative && isExpanded && hasDetailContent,
|
||||
});
|
||||
|
||||
const shimmerLabelStyle = useMemo(
|
||||
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2603,7 +2607,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: labelOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2614,7 +2618,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
],
|
||||
);
|
||||
|
||||
const shimmerSecondaryStyle = useMemo(
|
||||
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2623,7 +2627,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: secondaryOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2805,9 +2809,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
args?: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
@@ -2946,7 +2950,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
return () => {
|
||||
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,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||
import type {
|
||||
CheckStatus,
|
||||
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 {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
||||
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) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface PathRowProps {
|
||||
function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
void onSelect(path);
|
||||
onSelect(path);
|
||||
}, [onSelect, path]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -172,7 +172,7 @@ export function ProjectPickerModal() {
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options.length > 0 && activeIndex < options.length) {
|
||||
void handleSelectPath(options[activeIndex]!);
|
||||
void handleSelectPath(options[activeIndex]);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitCustom();
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ function CustomModelsSection(props: {
|
||||
},
|
||||
},
|
||||
});
|
||||
await refresh([provider as AgentProvider]);
|
||||
await refresh([provider]);
|
||||
},
|
||||
[patchConfig, provider, refresh],
|
||||
);
|
||||
@@ -284,7 +284,7 @@ export function ProviderDiagnosticSheet({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
@@ -308,7 +308,7 @@ export function ProviderDiagnosticSheet({
|
||||
if (!provider) {
|
||||
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");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("sidebar workspace render isolation", () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
...createWorkspaces()[1],
|
||||
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;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = null;
|
||||
|
||||
@@ -125,6 +125,26 @@ interface SplitPaneDropData {
|
||||
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"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
@@ -185,10 +205,10 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const wrapperStyle = useMemo(
|
||||
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
|
||||
[isVisible],
|
||||
);
|
||||
const wrapperStyle = useMemo(() => {
|
||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||
return { display, flex: 1 };
|
||||
}, [isVisible]);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
@@ -388,8 +408,8 @@ export function SplitContainer({
|
||||
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
if (data?.kind !== "workspace-tab") {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
@@ -406,11 +426,8 @@ export function SplitContainer({
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
@@ -513,11 +530,8 @@ export function SplitContainer({
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
@@ -905,17 +919,14 @@ function SplitPaneView({
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
const rawRef: unknown = paneRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const paneElement = rawRef;
|
||||
|
||||
const handlePanePointerDown = (event: PointerEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -142,6 +143,16 @@ function clamp(value: number, min: number, max: number): number {
|
||||
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 {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
@@ -229,20 +240,41 @@ export default function TerminalEmulator({
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
useDOMImperativeHandle(
|
||||
ref as Ref<DOMImperativeFactory>,
|
||||
() =>
|
||||
({
|
||||
writeOutput: (text: string) => {
|
||||
runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
domBridgeRef,
|
||||
(): DOMImperativeFactory => ({
|
||||
writeOutput: (...args) => {
|
||||
const text = args[0];
|
||||
if (typeof text === "string") runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (...args) => {
|
||||
const state = args[0];
|
||||
if (state === null) {
|
||||
runtimeRef.current?.renderSnapshot({ state: null });
|
||||
} else if (isTerminalState(state)) {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}) as unknown as DOMImperativeFactory,
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
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(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const SWIPE_MIN_PX = 22;
|
||||
@@ -376,7 +408,7 @@ export default function TerminalEmulator({
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
@@ -423,7 +455,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
return focusWithRetries({
|
||||
@@ -451,14 +483,14 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
@@ -566,7 +598,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
|
||||
@@ -228,8 +228,7 @@ export function ComboboxItem({
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.comboboxItem,
|
||||
Boolean(hovered) &&
|
||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
@@ -488,7 +487,7 @@ function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
|
||||
if (input.orderedVisibleOptions.length === 0) return;
|
||||
const { activeIndex, orderedVisibleOptions } = input;
|
||||
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||
input.handleSelect(orderedVisibleOptions[index]!.id);
|
||||
input.handleSelect(orderedVisibleOptions[index].id);
|
||||
}
|
||||
|
||||
interface FloatingSizeSetters {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -89,7 +88,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -175,25 +174,23 @@ function computePosition({
|
||||
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 {
|
||||
const wrapper = event as
|
||||
| {
|
||||
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
|
||||
const pageX = nativeEvent?.pageX;
|
||||
const pageY = nativeEvent?.pageY;
|
||||
if (typeof event !== "object" || event === null) {
|
||||
return null;
|
||||
}
|
||||
const nativeEvent = Reflect.get(event, "nativeEvent");
|
||||
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(native, "pageX");
|
||||
const pageY = Reflect.get(native, "pageY");
|
||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||
return { pageX, pageY };
|
||||
}
|
||||
const clientX = nativeEvent?.clientX;
|
||||
const clientY = nativeEvent?.clientY;
|
||||
const clientX = Reflect.get(native, "clientX");
|
||||
const clientY = Reflect.get(native, "clientY");
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
@@ -206,7 +203,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
openAtEvent(event as GestureResponderEvent);
|
||||
if (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||
}
|
||||
openAtEvent(event);
|
||||
},
|
||||
[openAtEvent],
|
||||
);
|
||||
@@ -337,7 +337,7 @@ export function ContextMenuTrigger({
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
return style({ pressed, hovered, open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
},
|
||||
@@ -434,40 +434,29 @@ export function ContextMenuContent({
|
||||
|
||||
// Measure trigger when opening (fallback) and capture point anchors.
|
||||
useEffect(() => {
|
||||
if (useMobileSheet) {
|
||||
if (useMobileSheet || !open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (anchorRect) {
|
||||
setTriggerRect(anchorRect);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({
|
||||
...rect,
|
||||
y: rect.y + statusBarHeight,
|
||||
});
|
||||
return;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -86,7 +86,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -748,12 +748,12 @@ export function DropdownMenuItem({
|
||||
return [
|
||||
styles.item,
|
||||
selectedStyle,
|
||||
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
|
||||
selected && (hovered || pressed) && selectedVariant !== "accent"
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -82,24 +81,20 @@ function shouldOpenOnFocus(): boolean {
|
||||
return !isWeb || lastInputWasKeyboard;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void,
|
||||
): (event: E) => void {
|
||||
return (event: E) => {
|
||||
original?.(event);
|
||||
injected?.(event);
|
||||
};
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
return typeof fn === "function";
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
function composeEventHandlers(
|
||||
original: unknown,
|
||||
injected: (event: unknown) => void,
|
||||
): (event: unknown) => void {
|
||||
return (event: unknown) => {
|
||||
if (isCallable(original)) {
|
||||
original(event);
|
||||
}
|
||||
injected(event);
|
||||
};
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
@@ -113,7 +108,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -320,7 +315,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverIn?.(e as never);
|
||||
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||
scheduleOpen();
|
||||
},
|
||||
[onHoverIn, scheduleOpen],
|
||||
@@ -328,7 +323,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverOut = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverOut?.(e as never);
|
||||
if (isCallable(onHoverOut)) onHoverOut(e);
|
||||
close();
|
||||
},
|
||||
[onHoverOut, close],
|
||||
@@ -336,7 +331,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: unknown) => {
|
||||
onFocus?.(e as never);
|
||||
if (isCallable(onFocus)) onFocus(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
if (!shouldOpenOnFocus()) return;
|
||||
clearOpenTimer();
|
||||
@@ -347,7 +342,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: unknown) => {
|
||||
onBlur?.(e as never);
|
||||
if (isCallable(onBlur)) onBlur(e);
|
||||
close();
|
||||
},
|
||||
[close, onBlur],
|
||||
@@ -355,7 +350,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handlePress = useCallback(
|
||||
(e: unknown) => {
|
||||
onPress?.(e as never);
|
||||
if (isCallable(onPress)) onPress(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -394,26 +389,33 @@ export function TooltipTrigger({
|
||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||
}
|
||||
|
||||
const childProps = child.props as Record<string, unknown>;
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
const rawProps: unknown = child.props;
|
||||
if (typeof rawProps !== "object" || rawProps === null) {
|
||||
throw new Error("TooltipTrigger asChild child must have props object");
|
||||
}
|
||||
const mergedProps: Record<string, unknown> = {
|
||||
...Object.assign({}, rawProps),
|
||||
...triggerProps,
|
||||
disabled: childProps.disabled || disabled,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
|
||||
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
|
||||
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
|
||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
|
||||
} as Record<string, unknown>;
|
||||
disabled: Reflect.get(rawProps, "disabled") || disabled,
|
||||
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
|
||||
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
|
||||
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
|
||||
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
|
||||
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
|
||||
};
|
||||
|
||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||
assignRef(existingRefProp, node);
|
||||
assignRef(ctx.triggerRef, node);
|
||||
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
|
||||
mergedProps[triggerRefProp] = (node: View) => {
|
||||
if (isCallable(existingRefProp)) {
|
||||
existingRefProp(node);
|
||||
} else if (existingRefProp && typeof existingRefProp === "object") {
|
||||
Object.assign(existingRefProp, { current: node });
|
||||
}
|
||||
Object.assign(ctx.triggerRef, { current: node });
|
||||
};
|
||||
|
||||
return cloneElement(child, mergedProps);
|
||||
@@ -453,16 +455,15 @@ export function TooltipContent({
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return;
|
||||
void measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -140,9 +140,9 @@ export function useWebScrollViewScrollbar(
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
if ("scrollToOffset" in scrollable) {
|
||||
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
||||
scrollable.scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
||||
scrollable.scrollTo({ y: offset, animated: false });
|
||||
}
|
||||
},
|
||||
[scrollableRef],
|
||||
|
||||
@@ -14,11 +14,7 @@ import {
|
||||
type TimelineReducerSideEffect,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
@@ -1201,7 +1197,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
const streamEvent = event;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
|
||||
@@ -57,9 +57,9 @@ export function useVoice() {
|
||||
export function useVoiceOptional(): VoiceContextValue | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribe : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
);
|
||||
|
||||
if (!runtime) {
|
||||
@@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice,
|
||||
stopVoice: runtime.stopVoice,
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||
toggleMute: runtime.toggleMute,
|
||||
startVoice: runtime.startVoice.bind(runtime),
|
||||
stopVoice: runtime.stopVoice.bind(runtime),
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
||||
toggleMute: runtime.toggleMute.bind(runtime),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ export function useVoiceTelemetry() {
|
||||
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribeTelemetry : noopSubscribe,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
||||
runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe,
|
||||
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||
);
|
||||
|
||||
return runtime ? snapshot : null;
|
||||
@@ -140,7 +140,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
runtimeRef.current = runtime;
|
||||
}
|
||||
|
||||
const engine = engineRef.current!;
|
||||
const engine = engineRef.current;
|
||||
const runtime = runtimeRef.current!;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("desktop attachment store", () => {
|
||||
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
||||
|
||||
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
|
||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
|
||||
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
||||
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||
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,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
@@ -39,72 +40,42 @@ function useDaemonManagementToggle(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => startDesktopDaemon())
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
})
|
||||
.finally(() => {
|
||||
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,
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
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,
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
startDaemon: startDesktopDaemon,
|
||||
stopDaemon: stopDesktopDaemon,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
.then((result) => {
|
||||
if (result.kind === "cancelled") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
|
||||
void updateSettings({ manageBuiltInDaemon: false })
|
||||
.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);
|
||||
});
|
||||
if (result.newStatus) {
|
||||
setStatus(result.newStatus);
|
||||
}
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]!);
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export class DictationStreamSender {
|
||||
let sent = 0;
|
||||
while (this.sendSeq < this.segments.length) {
|
||||
const seq = this.sendSeq;
|
||||
const audio = this.segments[seq]!;
|
||||
const audio = this.segments[seq];
|
||||
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
|
||||
this.sendSeq = seq + 1;
|
||||
sent += 1;
|
||||
|
||||
@@ -35,7 +35,7 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||
function makeProviderMap(
|
||||
...definitions: 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);
|
||||
|
||||
@@ -196,8 +196,8 @@ function resolveProvider(input: {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
return initialValues.provider;
|
||||
}
|
||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) {
|
||||
return preferences.provider as AgentProvider;
|
||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||
return preferences.provider;
|
||||
}
|
||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||
return null;
|
||||
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
|
||||
|
||||
const matchingProviders = new Set(
|
||||
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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
default: {
|
||||
getItem: async () => null,
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
function makeStorage() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: async (key: string) => map.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
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("__private__.resolveDraftKey", () => {
|
||||
it("returns an object draft key string unchanged", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: "draft:key",
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).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("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: " gpt-5.4-mini ",
|
||||
availableModels: [],
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "gpt-5.4-mini",
|
||||
},
|
||||
];
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the selected model when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "gpt-5.4-mini",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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");
|
||||
});
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
__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({
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
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,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
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 type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
|
||||
interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||
|
||||
interface UseAgentInputDraftInput {
|
||||
draftKey: DraftKeyInput;
|
||||
initialCwd?: string;
|
||||
@@ -57,121 +59,6 @@ interface AgentInputDraft {
|
||||
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 {
|
||||
const composerOptions = input.composer ?? null;
|
||||
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> {
|
||||
// Use expo-file-system to read the file
|
||||
const file = new File(uri);
|
||||
const base64 = await file.base64();
|
||||
const size = file.size;
|
||||
|
||||
// React Native doesn't support creating Blobs from binary data
|
||||
// Create a Blob-like object that has the methods we need
|
||||
const blobLike = {
|
||||
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;
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: "audio/m4a" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
// Monitor audio levels if metering is enabled
|
||||
// Use configRef to access the latest callback without recreating the effect
|
||||
useEffect(() => {
|
||||
const currentConfig = configRef.current;
|
||||
if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return;
|
||||
if (!configRef.current?.onAudioLevel || !recorderState.isRecording) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const metering = recorderState.metering;
|
||||
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));
|
||||
configRef.current?.onAudioLevel?.(normalized);
|
||||
}
|
||||
}, 100); // Check every 100ms
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [recorderState.metering, recorderState.isRecording]);
|
||||
@@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
await recorder.prepareToRecordAsync();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
await recorder.record();
|
||||
recorder.record();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
} catch (error) {
|
||||
setRecordingStartTime(null);
|
||||
if (error instanceof AttemptCancelledError) {
|
||||
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);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw new Error(`Failed to start audio recording: ${message}`, { cause: error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
} catch (error) {
|
||||
setRecordingStartTime(null);
|
||||
console.error("[AudioRecorder] Failed to stop recording:", error);
|
||||
throw new Error(
|
||||
`Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to stop audio recording: ${message}`, { cause: error });
|
||||
} finally {
|
||||
startStopMutexRef.current = null;
|
||||
}
|
||||
@@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
}, []);
|
||||
|
||||
const isRecording = useCallback(() => {
|
||||
return Boolean(recorderState.isRecording);
|
||||
return recorderState.isRecording;
|
||||
}, [recorderState.isRecording]);
|
||||
|
||||
// Return stable object using useMemo
|
||||
|
||||
@@ -341,7 +341,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
return () => {
|
||||
attemptGuard.cancel();
|
||||
try {
|
||||
void mediaRecorder.current?.stop();
|
||||
mediaRecorder.current?.stop();
|
||||
} catch {
|
||||
// Ignore stop during unmount.
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ export function useCommandCenter() {
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
||||
handleSelectItemRef.current(currentItems[index]!);
|
||||
handleSelectItemRef.current(currentItems[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i]! / 32768;
|
||||
out[i] = input[i] / 32768;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -209,8 +209,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
const decodeAudioData = useCallback(
|
||||
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
||||
const maybePromise = context.decodeAudioData(buffer);
|
||||
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
|
||||
return maybePromise as Promise<AudioBuffer>;
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
return maybePromise;
|
||||
}
|
||||
return await new Promise<AudioBuffer>((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();
|
||||
|
||||
@@ -347,7 +347,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
|
||||
const recorder = new RecorderCtor(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
} as MediaRecorderOptions) as MediaRecorder;
|
||||
} as MediaRecorderOptions);
|
||||
|
||||
const recorderRefs: RecorderRefs = {
|
||||
recorder,
|
||||
|
||||
@@ -260,7 +260,7 @@ export function useKeyboardShortcuts({
|
||||
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
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 =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
|
||||
@@ -86,7 +86,7 @@ export function usePrPaneData({
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||
const status = checkoutPrStatus.status;
|
||||
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
|
||||
const unsupportedKey =
|
||||
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
||||
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
||||
@@ -100,7 +100,7 @@ export function usePrPaneData({
|
||||
timelineUnsupported,
|
||||
});
|
||||
|
||||
const timelineQuery = useQuery<PullRequestTimeline, Error>({
|
||||
const timelineQuery = useQuery<PullRequestTimeline>({
|
||||
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
||||
queryFn: async () => {
|
||||
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
||||
|
||||
@@ -252,7 +252,6 @@ describe("useProjects", () => {
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ProjectHostError {
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
|
||||
@@ -235,16 +235,16 @@ export async function loadSettingsFromStorage(): Promise<Settings> {
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
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") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -750,7 +750,7 @@ function ChatAgentContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isInitializing = agentId ? isInitializingFromMap !== false : false;
|
||||
const isInitializing = agentId ? isInitializingFromMap : false;
|
||||
const isHistorySyncing = useMemo(() => {
|
||||
if (!agentId || !isInitializing) {
|
||||
return false;
|
||||
|
||||
@@ -21,7 +21,7 @@ export function polyfillCrypto(): void {
|
||||
if (typeof g.TextEncoder !== "function") {
|
||||
class BufferTextEncoder {
|
||||
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;
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("useInlineReviewController", () => {
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment!));
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment));
|
||||
expect(result.current.editor).toEqual({
|
||||
target: reviewTarget,
|
||||
commentId: savedComment?.id,
|
||||
@@ -208,8 +208,8 @@ describe("useInlineReviewController", () => {
|
||||
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
|
||||
act(() => result.current.onDeleteComment(updatedComment!.id));
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment));
|
||||
act(() => result.current.onDeleteComment(updatedComment.id));
|
||||
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ const { theme, projectsState, hostState, navigate, confirmDialogMock, client } =
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [] as ProjectHostError[],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -442,7 +441,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount: hosts.filter((host) => host.isOnline).length,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -451,7 +449,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -752,7 +749,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
||||
await flush();
|
||||
|
||||
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;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
@@ -767,7 +764,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
||||
// Subsequent saves use the freshly-returned revision.
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
const secondArg = client.writeProjectConfig.mock.calls[1]![0] as {
|
||||
const secondArg = client.writeProjectConfig.mock.calls[1][0] as {
|
||||
expectedRevision: PaseoConfigRevision;
|
||||
};
|
||||
expect(secondArg.expectedRevision).toEqual(newRevision);
|
||||
@@ -869,7 +866,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
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
|
||||
expect(savedConfig.worktree?.setup).toBe("npm install\nnpm run prepare");
|
||||
});
|
||||
@@ -897,7 +894,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -920,7 +917,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -943,7 +940,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -996,7 +993,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
||||
click(requireById("save-button"));
|
||||
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 ?? {});
|
||||
// One of the two scripts was removed.
|
||||
expect(scriptKeys.length).toBe(1);
|
||||
@@ -1031,7 +1028,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
||||
click(requireById("save-button"));
|
||||
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");
|
||||
const worktreeRecord = (savedConfig.worktree ?? {}) as Record<string, unknown>;
|
||||
expect(worktreeRecord.customWorktreeField).toBe("keep");
|
||||
|
||||
@@ -32,7 +32,6 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -231,7 +230,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -343,19 +340,12 @@ describe("ProjectsScreen", () => {
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
setProjectsState({ projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects 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");
|
||||
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
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) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
@@ -27,13 +27,9 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
<Text style={styles.emptyText}>No projects yet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1541,7 +1541,7 @@ function WorkspaceScreenContent({
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||
return await client.getCheckoutStatus(workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
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", () => {
|
||||
@@ -81,7 +81,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
||||
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", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("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([
|
||||
|
||||
@@ -91,7 +91,6 @@ describe("checkout-git-actions-store", () => {
|
||||
const first = store.commit({ serverId, cwd });
|
||||
const second = store.commit({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
|
||||
|
||||
deferred.resolve({});
|
||||
@@ -126,8 +125,9 @@ describe("checkout-git-actions-store", () => {
|
||||
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
|
||||
|
||||
expect(order).toEqual(["pull", "push"]);
|
||||
expect(client.checkoutPull).toHaveBeenCalledWith(cwd);
|
||||
expect(client.checkoutPush).toHaveBeenCalledWith(cwd);
|
||||
expect(
|
||||
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("does not push when pull fails for pull-and-push", async () => {
|
||||
@@ -146,7 +146,9 @@ describe("checkout-git-actions-store", () => {
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||
).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 () => {
|
||||
@@ -165,8 +167,9 @@ describe("checkout-git-actions-store", () => {
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||
).rejects.toThrow("push rejected");
|
||||
expect(client.checkoutPull).toHaveBeenCalledTimes(1);
|
||||
expect(client.checkoutPush).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
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 () => {
|
||||
@@ -212,7 +215,6 @@ describe("checkout-git-actions-store", () => {
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
const asyncStorageMock = vi.hoisted(() => {
|
||||
const storage = new Map<string, string>();
|
||||
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", () => ({
|
||||
default: asyncStorageMock,
|
||||
@@ -94,10 +100,17 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
|
||||
describe("navigation active workspace store", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.storage.clear();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
asyncStorageMock.getItem.mockImplementation(
|
||||
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(() => {
|
||||
@@ -222,7 +235,6 @@ describe("navigation active workspace store", () => {
|
||||
it("hydrates empty and corrupt workspace route storage as null", async () => {
|
||||
installWindowStub("/open-project");
|
||||
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(null);
|
||||
let store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
@@ -244,10 +256,16 @@ describe("navigation active workspace store", () => {
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
await Promise.resolve(); // flush the fire-and-forget setItem microtask
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||
);
|
||||
vi.resetModules();
|
||||
const freshStore = await import("@/stores/navigation-active-workspace-store");
|
||||
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;
|
||||
timestamp: number;
|
||||
toolName: string;
|
||||
args: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
args: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
status: "executing" | "completed" | "failed";
|
||||
};
|
||||
|
||||
@@ -130,8 +130,8 @@ export function normalizeWorkspaceDescriptor(
|
||||
payload: WorkspaceDescriptorPayload,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: normalizeWorkspaceOpaqueId(String(payload.id)) ?? String(payload.id),
|
||||
projectId: String(payload.projectId),
|
||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
|
||||
@@ -526,7 +526,7 @@ function findNearestSiblingPaneId(root: SplitNodeInternal, paneId: string): stri
|
||||
|
||||
for (let depth = path.length - 1; depth >= 0; depth -= 1) {
|
||||
const parentPath = path.slice(0, depth);
|
||||
const childIndex = path[depth]!;
|
||||
const childIndex = path[depth];
|
||||
const parentNode = getNodeAtPath(root, parentPath);
|
||||
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 removeIndex = path[path.length - 1]!;
|
||||
const removeIndex = path[path.length - 1];
|
||||
const parentNode = getNodeAtPath(root, parentPath);
|
||||
invariant(parentNode.kind === "group", "Expected parent group while removing pane");
|
||||
|
||||
@@ -676,7 +676,7 @@ function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInt
|
||||
|
||||
const nextParentNode =
|
||||
nextParentChildren.length === 1
|
||||
? nextParentChildren[0]!
|
||||
? nextParentChildren[0]
|
||||
: createGroupNode({
|
||||
id: parentNode.group.id,
|
||||
direction: parentNode.group.direction,
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||
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.children).toHaveLength(2);
|
||||
@@ -259,7 +259,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
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(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||
@@ -280,7 +280,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "setup",
|
||||
workspaceId: "ws-main",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(agentTabId).toBe("agent_agent-1");
|
||||
@@ -306,7 +306,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
});
|
||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layoutAfter.root, "main")!;
|
||||
|
||||
expect(duplicateTabId).toBe(firstTabId);
|
||||
@@ -380,7 +380,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
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(secondTabId).toBe("draft-2");
|
||||
@@ -416,14 +416,14 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "draft",
|
||||
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(draftTabId).toBe("draft-split");
|
||||
expect(layout.focusedPaneId).toBe(newPaneId);
|
||||
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!)?.focusedTabId).toBe(draftTabId);
|
||||
expect(findPaneById(layout.root, newPaneId)?.tabIds).toEqual([draftTabId!]);
|
||||
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId);
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
@@ -448,14 +448,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusTab(workspaceKey, fileTabId!);
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusTab(workspaceKey, terminalTabId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
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", () => {
|
||||
@@ -474,8 +474,8 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const splitPane = findPaneById(layout.root, splitPaneId);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
@@ -501,7 +501,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/retargeted.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(draftTabId).toBe("draft-retarget");
|
||||
expect(nextTabId).toBe(draftTabId);
|
||||
@@ -541,7 +541,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
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(draftTabId).toBe("draft-dup");
|
||||
@@ -576,7 +576,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstAgentTabId).toBe(firstDraftTabId);
|
||||
expect(nextTabId).toBe(firstDraftTabId);
|
||||
@@ -608,7 +608,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(findPaneById(layout.root, "main")).toEqual({
|
||||
id: "main",
|
||||
@@ -663,11 +663,11 @@ describe("workspace-layout-store actions", () => {
|
||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||
store.focusPane(workspaceKey, "main");
|
||||
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(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, splitPaneId!)).toEqual({
|
||||
expect(findPaneById(layout.root, splitPaneId)).toEqual({
|
||||
id: splitPaneId,
|
||||
tabIds: [fourthTabId!, thirdTabId!],
|
||||
focusedTabId: fourthTabId,
|
||||
@@ -705,7 +705,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusPane(workspaceKey, splitPaneId!);
|
||||
@@ -734,7 +734,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
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(layout.focusedPaneId).toBe("main");
|
||||
@@ -782,7 +782,7 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
@@ -812,11 +812,11 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout.focusedPaneId).toBe(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/a.ts",
|
||||
]);
|
||||
@@ -852,7 +852,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||
@@ -910,7 +910,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
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(duplicateTabId).toBe(secondTabId);
|
||||
@@ -946,14 +946,14 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
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]);
|
||||
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
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);
|
||||
|
||||
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" });
|
||||
store.closeTab(workspaceKey, tabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout).toEqual(createDefaultLayout());
|
||||
});
|
||||
@@ -1065,7 +1065,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
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(nextTabId).toBe("agent_agent-1");
|
||||
@@ -1129,7 +1129,7 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
@@ -1189,7 +1189,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
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(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,14 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
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[] {
|
||||
if (!Array.isArray(list)) {
|
||||
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 {
|
||||
rawUiTabsByWorkspace: Record<string, unknown>;
|
||||
rawFocused: Record<string, unknown>;
|
||||
@@ -113,42 +110,69 @@ interface MigrationRawSources {
|
||||
}
|
||||
|
||||
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
||||
const legacy = persistedState as LegacyPersistedWorkspaceTabsState | undefined;
|
||||
const rawState = ((legacy as { state?: Record<string, unknown> } | undefined)?.state ??
|
||||
legacy ??
|
||||
{}) as Record<string, unknown>;
|
||||
const top = toObjectRecord(persistedState) ?? {};
|
||||
const rawState = toObjectRecord(top.state) ?? top;
|
||||
|
||||
return {
|
||||
rawUiTabsByWorkspace: (rawState.uiTabsByWorkspace ??
|
||||
rawState.openTabsByWorkspace ??
|
||||
legacy?.uiTabsByWorkspace ??
|
||||
legacy?.openTabsByWorkspace ??
|
||||
{}) as Record<string, unknown>,
|
||||
rawFocused: (rawState.focusedTabIdByWorkspace ??
|
||||
legacy?.focusedTabIdByWorkspace ??
|
||||
rawState.lastFocusedTabByWorkspace ??
|
||||
{}) as Record<string, unknown>,
|
||||
rawOrder: (rawState.tabOrderByWorkspace ??
|
||||
legacy?.tabOrderByWorkspace ??
|
||||
rawState.tabOrderByWorkspace ??
|
||||
{}) as Record<string, unknown>,
|
||||
legacyOrder: (rawState.tabOrderByWorkspace ??
|
||||
rawState.tabOrderLegacyByWorkspace ??
|
||||
{}) as Record<string, unknown>,
|
||||
rawUiTabsByWorkspace:
|
||||
toObjectRecord(
|
||||
rawState.uiTabsByWorkspace ??
|
||||
rawState.openTabsByWorkspace ??
|
||||
top.uiTabsByWorkspace ??
|
||||
top.openTabsByWorkspace,
|
||||
) ?? {},
|
||||
rawFocused:
|
||||
toObjectRecord(
|
||||
rawState.focusedTabIdByWorkspace ??
|
||||
rawState.lastFocusedTabByWorkspace ??
|
||||
top.focusedTabIdByWorkspace,
|
||||
) ?? {},
|
||||
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
|
||||
legacyOrder:
|
||||
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 {
|
||||
if (!rawTab || typeof rawTab !== "object") {
|
||||
const record = toObjectRecord(rawTab);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
||||
const rawTarget = toObjectRecord(record.target);
|
||||
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
|
||||
if (!normalizedTarget) {
|
||||
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 rawCreatedAt = (rawTab as WorkspaceTab).createdAt;
|
||||
const rawCreatedAt = record.createdAt;
|
||||
return {
|
||||
tabId,
|
||||
target: normalizedTarget,
|
||||
|
||||
@@ -134,9 +134,8 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
return [...existing, profile];
|
||||
}
|
||||
|
||||
const matchedProfiles = matchingIndexes.map((index) => existing[index]!);
|
||||
const prev =
|
||||
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!;
|
||||
const matchedProfiles = matchingIndexes.map((index) => existing[index]);
|
||||
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
||||
const nextConnections = dedupeHostConnections([
|
||||
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||
input.connection,
|
||||
@@ -180,7 +179,7 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const firstIndex = matchingIndexes[0]!;
|
||||
const firstIndex = matchingIndexes[0];
|
||||
const matchingIndexSet = new Set(matchingIndexes);
|
||||
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index));
|
||||
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 {
|
||||
if (!connection || typeof connection !== "object") {
|
||||
const record = toObjectRecord(connection);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const record = connection as Record<string, unknown>;
|
||||
const type = typeof record.type === "string" ? record.type : null;
|
||||
if (type === "directTcp") {
|
||||
try {
|
||||
const endpoint = normalizeLoopbackToLocalhost(
|
||||
normalizeHostPort(String(record.endpoint ?? "")),
|
||||
normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""),
|
||||
);
|
||||
return DirectTcpHostConnectionSchema.parse({
|
||||
id: `direct:${endpoint}`,
|
||||
@@ -254,17 +261,21 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (type === "relay") {
|
||||
try {
|
||||
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ""));
|
||||
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? "").trim();
|
||||
const relayEndpoint = normalizeHostPort(
|
||||
typeof record.relayEndpoint === "string" ? record.relayEndpoint : "",
|
||||
);
|
||||
const daemonPublicKeyB64 = (
|
||||
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
|
||||
).trim();
|
||||
if (!daemonPublicKeyB64) return null;
|
||||
return {
|
||||
id: `relay:${relayEndpoint}`,
|
||||
@@ -281,10 +292,10 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
}
|
||||
|
||||
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
const record = toObjectRecord(entry);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||
if (!serverId) {
|
||||
return null;
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface AgentToolCallData {
|
||||
callId: string;
|
||||
name: string;
|
||||
status: AgentToolCallStatus;
|
||||
error: unknown | null;
|
||||
error: unknown;
|
||||
detail: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -331,7 +331,7 @@ function hasNonEmptyObject(value: unknown): boolean {
|
||||
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) {
|
||||
return existing;
|
||||
}
|
||||
@@ -406,7 +406,7 @@ export function mergeToolCallDetail(
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function inputFromUnknownDetail(detail: ToolCallDetail): unknown | null {
|
||||
function inputFromUnknownDetail(detail: ToolCallDetail): unknown {
|
||||
return detail.type === "unknown" ? detail.input : null;
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ function appendTodoList(
|
||||
): StreamItem[] {
|
||||
const normalizedItems = items.map((item) => ({
|
||||
text: item.text,
|
||||
completed: Boolean(item.completed),
|
||||
completed: item.completed,
|
||||
}));
|
||||
|
||||
const lastItem = state[state.length - 1];
|
||||
@@ -543,10 +543,6 @@ function appendTodoList(
|
||||
return [...state, entry];
|
||||
}
|
||||
|
||||
function formatErrorMessage(message: string): string {
|
||||
return `Agent error\n${message}`;
|
||||
}
|
||||
|
||||
function reduceTimelineToolCall(
|
||||
state: StreamItem[],
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
@@ -662,7 +658,7 @@ function reduceTimelineEvent(
|
||||
}
|
||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||
text: todo.text,
|
||||
completed: Boolean(todo.completed),
|
||||
completed: todo.completed,
|
||||
}));
|
||||
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
||||
}
|
||||
@@ -672,7 +668,7 @@ function reduceTimelineEvent(
|
||||
id: createTimelineId("error", item.message ?? "", timestamp),
|
||||
timestamp,
|
||||
activityType: "error",
|
||||
message: formatErrorMessage(item.message ?? "Unknown error"),
|
||||
message: item.message ?? "Unknown error",
|
||||
};
|
||||
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 { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
|
||||
type AgentDirectoryFetchEntry = FetchAgentsEntry | FetchAgentHistoryEntry;
|
||||
type AgentDirectoryFetchEntry = FetchAgentsEntry;
|
||||
|
||||
interface PendingPermissionEntry {
|
||||
key: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
@@ -31,7 +30,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
status: snapshot.status,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastUserMessageAt,
|
||||
|
||||
@@ -285,18 +285,12 @@ describe("highlightDiffFile", () => {
|
||||
|
||||
// First content line: "const foo = 1;"
|
||||
const constLine = hunk.lines[1];
|
||||
expect(constLine.tokens).toBeDefined();
|
||||
expect(constLine.tokens!.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have a "keyword" token for "const"
|
||||
const constToken = constLine.tokens!.find((t) => t.text === "const");
|
||||
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");
|
||||
expect(constLine.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||
);
|
||||
expect(constLine.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "1", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights both added and removed lines correctly", () => {
|
||||
@@ -306,15 +300,15 @@ describe("highlightDiffFile", () => {
|
||||
|
||||
// Removed line: "const bar = 2;"
|
||||
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
||||
expect(removedLine?.tokens).toBeDefined();
|
||||
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
|
||||
expect(removedNumber?.style).toBe("number");
|
||||
expect(removedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
|
||||
// Added line: "const bar = 3;"
|
||||
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
|
||||
expect(addedNumber?.style).toBe("number");
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "3", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not modify unsupported file types", () => {
|
||||
@@ -351,15 +345,16 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const fnLine = hunk.lines[1];
|
||||
expect(fnLine.tokens).toBeDefined();
|
||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
||||
expect(hunk.lines[1].tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "fn", style: "keyword" }),
|
||||
);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to C code", () => {
|
||||
@@ -367,15 +362,14 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const mainLine = hunk.lines[1];
|
||||
expect(mainLine.tokens).toBeDefined();
|
||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
||||
expect(hunk.lines[1].tokens?.length).toBeGreaterThan(0);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Java code", () => {
|
||||
@@ -383,15 +377,16 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const classLine = hunk.lines[1];
|
||||
expect(classLine.tokens).toBeDefined();
|
||||
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
|
||||
expect(hunk.lines[1].tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "public", style: "keyword" }),
|
||||
);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||
@@ -402,8 +397,9 @@ describe("highlightDiffFile", () => {
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Go code", () => {
|
||||
@@ -413,8 +409,9 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to PHP code", () => {
|
||||
@@ -424,8 +421,9 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("$version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to YAML code", () => {
|
||||
@@ -435,8 +433,7 @@ describe("highlightDiffFile", () => {
|
||||
(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", () => {
|
||||
@@ -446,8 +443,7 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("<count>"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(expect.objectContaining({ style: "tag" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,7 +452,9 @@ describe("parseAndHighlightDiff", () => {
|
||||
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
|
||||
export type ShortcutKey = string;
|
||||
|
||||
export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("sendOsNotification", () => {
|
||||
clicked.clickListeners[0]?.({} as Event);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const event = dispatchEvent.mock.calls[0]?.[0] as unknown as {
|
||||
const event = dispatchEvent.mock.calls[0]?.[0] as {
|
||||
type?: string;
|
||||
detail?: { data?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
@@ -132,7 +132,7 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
|
||||
cancelable: true,
|
||||
},
|
||||
);
|
||||
return dispatch(event) === false;
|
||||
return !dispatch(event);
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} 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 PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -335,3 +341,38 @@ describe("formatAge", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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