mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
39 Commits
v0.1.70-be
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e802e92cd | ||
|
|
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 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -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": [
|
||||
|
||||
@@ -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).
|
||||
|
||||
103
package-lock.json
generated
103
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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,12 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
|
||||
// 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 }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +14,94 @@ 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 },
|
||||
],
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
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;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -77,7 +77,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
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;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -120,7 +120,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -170,7 +170,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
|
||||
@@ -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,
|
||||
|
||||
57
packages/app/scripts/lower-thinking-tone.mjs
Normal file
57
packages/app/scripts/lower-thinking-tone.mjs
Normal file
@@ -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
|
||||
|
||||
@@ -9,8 +9,12 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
|
||||
export const composerWorkspaceAttachment = {
|
||||
is: isWorkspaceAttachment,
|
||||
renderPill,
|
||||
toSubmitAttachment,
|
||||
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
35
packages/app/src/attachments/workspace-attachment-utils.ts
Normal file
35
packages/app/src/attachments/workspace-attachment-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
attachments: readonly ComposerAttachment[],
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
export function workspaceAttachmentToSubmitAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
|
||||
map.set(
|
||||
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("");
|
||||
},
|
||||
|
||||
715
packages/app/src/components/composer-actions.test.ts
Normal file
715
packages/app/src/components/composer-actions.test.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "./composer-actions";
|
||||
|
||||
const imageMetadata: AttachmentMetadata = {
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "img-1",
|
||||
fileName: "img-1.png",
|
||||
byteSize: 42,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const issueItem: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 101,
|
||||
title: "Fix composer attachments",
|
||||
url: "https://github.com/acme/paseo/issues/101",
|
||||
state: "open",
|
||||
body: "Issue body",
|
||||
labels: ["composer"],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
const prItem: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
state: "open",
|
||||
body: "PR body",
|
||||
labels: ["composer"],
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
};
|
||||
|
||||
function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body,
|
||||
context: {
|
||||
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: `review:${body}`,
|
||||
commentCount: 1,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementWorkspaceAttachment(): Extract<
|
||||
WorkspaceComposerAttachment,
|
||||
{ kind: "browser_element" }
|
||||
> {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com/page",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Save",
|
||||
outerHTML: '<button class="primary">Save</button>',
|
||||
computedStyles: { display: "flex" },
|
||||
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||
reactSource: null,
|
||||
parentChain: ["form.settings"],
|
||||
children: [],
|
||||
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePersister(): AttachmentPersister & {
|
||||
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||
deletedBatches: AttachmentMetadata[][];
|
||||
} {
|
||||
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||
const deletedBatches: AttachmentMetadata[][] = [];
|
||||
return {
|
||||
blobCalls,
|
||||
fileUriCalls,
|
||||
deletedBatches,
|
||||
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||
blobCalls.push({ blob, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||
},
|
||||
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||
fileUriCalls.push({ uri, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||
},
|
||||
deleteAttachments: (metadata) => {
|
||||
deletedBatches.push(metadata);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeSendCall {
|
||||
agentId: string;
|
||||
text: string;
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: AgentAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
if (options.rejection) {
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
|
||||
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||
state: new Map(initial),
|
||||
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||
write: (updater) => {
|
||||
fake.state = updater(fake.state);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||
|
||||
describe("cancelComposerAgent", () => {
|
||||
function baseInput(): {
|
||||
client: ComposerCancelClient & { canceledIds: string[] };
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
} {
|
||||
const canceledIds: string[] = [];
|
||||
return {
|
||||
client: {
|
||||
canceledIds,
|
||||
cancelAgent: async (id) => {
|
||||
canceledIds.push(id);
|
||||
},
|
||||
},
|
||||
agentId: "agent",
|
||||
isAgentRunning: true,
|
||||
isCancellingAgent: false,
|
||||
isConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent(input);
|
||||
expect(result).toBe(true);
|
||||
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is not running", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is already being canceled", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when disconnected or the client is null", () => {
|
||||
const input = baseInput();
|
||||
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAndPersistImages", () => {
|
||||
it("returns [] when the picker yields nothing", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => null,
|
||||
persister,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
expect(persister.blobCalls).toEqual([]);
|
||||
expect(persister.fileUriCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||
const persister = createFakePersister();
|
||||
const blob = new Blob(["image"]);
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||
});
|
||||
|
||||
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.fileUriCalls).toEqual([
|
||||
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const image = imageWithId("img-2");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "send attachments",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
const [call] = client.calls;
|
||||
expect(call.agentId).toBe("agent");
|
||||
expect(call.text).toBe("send attachments");
|
||||
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||
expect(call.options.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
expect(userMessage.kind).toBe("user_message");
|
||||
expect(userMessage.text).toBe("send attachments");
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
const existingItem: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "prior",
|
||||
text: "prior",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||
const client = createFakeSendClient();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
images: [],
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
expect(client.calls[0]?.options.images).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const browserElement = browserElementWorkspaceAttachment();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Browser element · button",
|
||||
text: browserElement.attachment.formatted,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueComposerMessage", () => {
|
||||
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " draft ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(result.queued?.text).toBe("draft");
|
||||
expect(queue.state.get("agent")).toEqual([
|
||||
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue an empty message with no attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
expect(result.queued).toBeNull();
|
||||
expect(queue.state.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||
const image = imageWithId("img-queue");
|
||||
queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: "queue this",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||
{ kind: "image", metadata: image },
|
||||
review,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editQueuedComposerMessage", () => {
|
||||
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||
);
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||
expect(result).toBeNull();
|
||||
expect(queue.state.get("agent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||
const image = imageWithId("img-queued-edit");
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||
expect(result).toEqual({
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
});
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendQueuedComposerMessageNow", () => {
|
||||
it("returns missing without submitting when the message id is gone", async () => {
|
||||
const queue = createFakeQueue();
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "missing" });
|
||||
expect(submitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the queued entry and submits its text + attachments", async () => {
|
||||
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||
);
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "submitted" });
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||
});
|
||||
|
||||
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{ id: "msg-1", text: "first", attachments: [] },
|
||||
{ id: "msg-2", text: "second", attachments: [] },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||
const state = queue.state.get("agent");
|
||||
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeComposerAttachmentAtIndex", () => {
|
||||
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||
const image = imageWithId("img-remove");
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([]);
|
||||
expect(persister.deletedBatches).toEqual([[image]]);
|
||||
});
|
||||
|
||||
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
expect(persister.deletedBatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openComposerAttachment", () => {
|
||||
it("opens the lightbox for image attachments", () => {
|
||||
const image = imageWithId("img-body");
|
||||
const lightboxCalls: AttachmentMetadata[] = [];
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "image", metadata: image },
|
||||
setLightboxMetadata: (metadata) => {
|
||||
lightboxCalls.push(metadata);
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(lightboxCalls).toEqual([image]);
|
||||
expect(externalUrlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("delegates workspace review attachments to the workspace opener", () => {
|
||||
const review = reviewWorkspaceAttachment("Open me.");
|
||||
const workspaceCalls: ComposerAttachment[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: review,
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: ({ attachment }) => {
|
||||
workspaceCalls.push(attachment);
|
||||
return true;
|
||||
},
|
||||
openExternalUrl: () => {
|
||||
throw new Error("unexpected external url call");
|
||||
},
|
||||
});
|
||||
expect(workspaceCalls).toEqual([review]);
|
||||
});
|
||||
|
||||
it("opens GitHub item URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "github_issue", item: issueItem },
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
it("appends a GitHub issue when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], issueItem);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
});
|
||||
|
||||
it("appends a GitHub PR when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], prItem);
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("removes an existing GitHub item with the same kind+number", () => {
|
||||
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not affect other items with different kind or number", () => {
|
||||
const start: UserComposerAttachment[] = [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
];
|
||||
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||
const next = toggleGithubAttachment(start, otherIssue);
|
||||
expect(next).toEqual([
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
{ kind: "github_issue", item: otherIssue },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||
const attachments: ComposerAttachment[] = [
|
||||
{ kind: "image", metadata: imageWithId("img-x") },
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
reviewWorkspaceAttachment("ignored"),
|
||||
];
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||
});
|
||||
});
|
||||
325
packages/app/src/components/composer-actions.ts
Normal file
325
packages/app/src/components/composer-actions.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
|
||||
export interface AttachmentPersister {
|
||||
persistFromBlob: (input: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
persistFromFileUri: (input: {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComposerSendClient {
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
read: (agentId: string) => QueuedComposerMessage[];
|
||||
write: (
|
||||
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export async function pickAndPersistImages(input: {
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||
}): Promise<AttachmentMetadata[]> {
|
||||
const result = await input.pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (picked) => {
|
||||
const fileName = picked.fileName ?? null;
|
||||
const mimeType = picked.mimeType || "image/jpeg";
|
||||
if (picked.source.kind === "blob") {
|
||||
return await input.persister.persistFromBlob({
|
||||
blob: picked.source.blob,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
return await input.persister.persistFromFileUri({
|
||||
uri: picked.source.uri,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||
attachments: T[];
|
||||
index: number;
|
||||
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||
}): T[] {
|
||||
const removed = input.attachments[input.index];
|
||||
if (removed?.kind === "image") {
|
||||
void input.deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return input.attachments.filter((_, i) => i !== input.index);
|
||||
}
|
||||
|
||||
export interface CancelComposerAgentInput {
|
||||
client: ComposerCancelClient | null;
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||
if (!input.isConnected || !input.client) return false;
|
||||
void input.client.cancelAgent(input.agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchComposerAgentMessageInput {
|
||||
client: ComposerSendClient;
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const messageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: StreamItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
const head = stream.getHead(agentId);
|
||||
if (head && head.length > 0) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageResult {
|
||||
queued: QueuedComposerMessage | null;
|
||||
}
|
||||
|
||||
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed && input.attachments.length === 0) {
|
||||
return { queued: null };
|
||||
}
|
||||
const item: QueuedComposerMessage = {
|
||||
id: generateMessageId(),
|
||||
text: trimmed,
|
||||
attachments: input.attachments,
|
||||
};
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||
return next;
|
||||
});
|
||||
return { queued: item };
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageResult {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
}
|
||||
|
||||
export function editQueuedComposerMessage(
|
||||
input: EditQueuedComposerMessageInput,
|
||||
): EditQueuedComposerMessageResult | null {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return null;
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
text: item.text,
|
||||
attachments: userAttachmentsOnly(item.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendQueuedComposerMessageNowInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
| { status: "missing" }
|
||||
| { status: "submitted" }
|
||||
| { status: "failed"; errorMessage: string };
|
||||
|
||||
export async function sendQueuedComposerMessageNow(
|
||||
input: SendQueuedComposerMessageNowInput,
|
||||
): Promise<SendQueuedComposerMessageNowResult> {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return { status: "missing" };
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||
return { status: "submitted" };
|
||||
} catch (error) {
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenComposerAttachmentInput {
|
||||
attachment: ComposerAttachment;
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||
openExternalUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||
if (input.attachment.kind === "image") {
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||
return;
|
||||
}
|
||||
input.openExternalUrl(input.attachment.item.url);
|
||||
}
|
||||
|
||||
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
export function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
|
||||
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
continue;
|
||||
}
|
||||
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
reviewAttachments.push(workspaceAttachment);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
AgentStatusBar,
|
||||
DraftAgentStatusBar,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
MessageInput,
|
||||
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/components/composer-actions";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -79,17 +93,12 @@ import type {
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
|
||||
interface QueuedMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
type AttachmentListUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -128,52 +137,18 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
|
||||
async function pickAndPersistImages(
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
|
||||
): Promise<ImageAttachment[]> {
|
||||
const result = await pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === "blob") {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
|
||||
const removed = prev[index];
|
||||
if (removed?.kind === "image") {
|
||||
void deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return prev.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -240,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
}
|
||||
|
||||
function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return composerWorkspaceAttachment
|
||||
.userAttachmentsOnly(current)
|
||||
.some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
|
||||
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface RenderAttachmentPreviewListArgs {
|
||||
selectedAttachments: ComposerAttachment[];
|
||||
isComposerLocked: boolean;
|
||||
@@ -419,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface DispatchAgentMessageSendArgs {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
serverId: string;
|
||||
targetAgentId: string;
|
||||
text: string;
|
||||
sendAttachments: ComposerAttachment[];
|
||||
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
|
||||
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
|
||||
): void {
|
||||
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
|
||||
const currentHead = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
|
||||
if (currentHead && currentHead.length > 0) {
|
||||
setAgentStreamHead(serverId, (prev) => {
|
||||
const head = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...head, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const currentStream = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...currentStream, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
|
||||
const { client, targetAgentId, text, sendAttachments } = args;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: clientMessageId,
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream({ ...args, userMessage });
|
||||
const imagesData = await encodeImages(wirePayload.images);
|
||||
await client.sendAgentMessage(targetAgentId, text, {
|
||||
messageId: clientMessageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function openComposerAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
||||
): void {
|
||||
if (attachment.kind === "image") {
|
||||
setLightboxMetadata(attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
openWorkspaceAttachment({ attachment });
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(attachment.item.url);
|
||||
}
|
||||
|
||||
interface CancelRunningAgentArgs {
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
agentIdRef: { current: string };
|
||||
setIsCancellingAgent: (value: boolean) => void;
|
||||
messageInputRef: { current: MessageInputRef | null };
|
||||
}
|
||||
|
||||
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
|
||||
const {
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
} = args;
|
||||
if (!isAgentRunning || isCancellingAgent) return;
|
||||
if (!isConnected || !client) return;
|
||||
setIsCancellingAgent(true);
|
||||
void client.cancelAgent(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||
current: MessageInputRef | null;
|
||||
}): void {
|
||||
@@ -1155,14 +993,18 @@ export function Composer({
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
await dispatchAgentMessageSend({
|
||||
const stream: AgentStreamWriter = {
|
||||
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||
};
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
serverId,
|
||||
targetAgentId,
|
||||
agentId: targetAgentId,
|
||||
text,
|
||||
sendAttachments,
|
||||
setAgentStreamHead,
|
||||
setAgentStreamTail,
|
||||
attachments: sendAttachments,
|
||||
encodeImages,
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
@@ -1175,33 +1017,23 @@ export function Composer({
|
||||
const isAgentRunning = agentState.status === "running";
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, serverId, setQueuedMessages],
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
() => ({
|
||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
write: (updater) => setQueuedMessages(serverId, updater),
|
||||
}),
|
||||
[serverId, setQueuedMessages],
|
||||
);
|
||||
|
||||
const queueMessage = useCallback(
|
||||
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
||||
const trimmedMessage = queuedMessage.trim();
|
||||
if (!trimmedMessage && queuedAttachments.length === 0) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: trimmedMessage,
|
||||
const result = queueComposerMessage({
|
||||
agentId,
|
||||
text: queuedMessage,
|
||||
attachments: queuedAttachments,
|
||||
};
|
||||
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||
return next;
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result.queued) return;
|
||||
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
@@ -1211,9 +1043,8 @@ export function Composer({
|
||||
[
|
||||
agentId,
|
||||
clearSentAttachments,
|
||||
queueWriter,
|
||||
resetSuppression,
|
||||
serverId,
|
||||
setQueuedMessages,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
],
|
||||
@@ -1287,7 +1118,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const handlePickImage = useCallback(async () => {
|
||||
const newImages = await pickAndPersistImages(pickImages);
|
||||
const newImages = await pickAndPersistImages({
|
||||
pickImages,
|
||||
persister: {
|
||||
persistFromBlob: ({ blob, mimeType, fileName }) =>
|
||||
persistAttachmentFromBlob({ blob, mimeType, fileName }),
|
||||
persistFromFileUri: ({ uri, mimeType, fileName }) =>
|
||||
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
|
||||
},
|
||||
});
|
||||
if (newImages.length === 0) return;
|
||||
addImages(newImages);
|
||||
}, [addImages, pickImages]);
|
||||
@@ -1301,14 +1140,23 @@ export function Composer({
|
||||
if (didRemoveWorkspaceAttachment) {
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
||||
setSelectedAttachments((prev) =>
|
||||
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||
);
|
||||
},
|
||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const handleOpenAttachment = useCallback(
|
||||
(attachment: ComposerAttachment) => {
|
||||
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
||||
openComposerAttachment({
|
||||
attachment,
|
||||
setLightboxMetadata,
|
||||
openWorkspaceAttachment: openAttachment,
|
||||
openExternalUrl: (url) => {
|
||||
void openExternalUrl(url);
|
||||
},
|
||||
});
|
||||
},
|
||||
[openAttachment],
|
||||
);
|
||||
@@ -1320,15 +1168,16 @@ export function Composer({
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
const handleCancelAgent = useCallback(() => {
|
||||
cancelRunningAgent({
|
||||
const didCancel = cancelComposerAgent({
|
||||
client,
|
||||
agentId: agentIdRef.current,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
});
|
||||
if (!didCancel) return;
|
||||
setIsCancellingAgent(true);
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||
|
||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||
@@ -1394,33 +1243,34 @@ export function Composer({
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
setUserInput(item.text);
|
||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
||||
const result = editQueuedComposerMessage({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result) return;
|
||||
setUserInput(result.text);
|
||||
setSelectedAttachments(result.attachments);
|
||||
},
|
||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
||||
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||
);
|
||||
|
||||
const handleSendQueuedNow = useCallback(
|
||||
async (id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||
try {
|
||||
await submitMessage(item.text, item.attachments);
|
||||
} catch (error) {
|
||||
updateQueue((current) => [item, ...current]);
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
submitMessage(text, queuedAttachments),
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[queuedMessages, submitMessage, updateQueue],
|
||||
[agentId, queueWriter, submitMessage],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("buildProjects", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters non-GitHub remote projects while keeping GitHub and local projects", () => {
|
||||
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
@@ -368,20 +368,30 @@ describe("buildProjects", () => {
|
||||
id: "github",
|
||||
repoRoot: "/repo/github",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
projectKey: "remote:github.com/acme/web",
|
||||
projectName: "acme/web",
|
||||
cwd: "/repo/github",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
remoteUrl: "https://github.com/acme/web.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
projectKey: "remote:gitlab.com/acme/api",
|
||||
projectName: "acme/api",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
remoteUrl: "https://gitlab.com/acme/api.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "bitbucket",
|
||||
repoRoot: "/repo/bitbucket",
|
||||
project: placement({
|
||||
projectKey: "remote:bitbucket.org/acme/cli",
|
||||
projectName: "acme/cli",
|
||||
cwd: "/repo/bitbucket",
|
||||
remoteUrl: "https://bitbucket.org/acme/cli.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
@@ -399,14 +409,15 @@ describe("buildProjects", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"remote:github.com/acme/app",
|
||||
"remote:gitlab.com/acme/api",
|
||||
"remote:bitbucket.org/acme/cli",
|
||||
"remote:github.com/acme/web",
|
||||
"/repo/local",
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => {
|
||||
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
@@ -419,7 +430,7 @@ describe("buildProjects", () => {
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
@@ -429,8 +440,48 @@ describe("buildProjects", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
|
||||
expect(result.projects[0]?.projectName).toBe("acme/app");
|
||||
expect(result.projects[0]?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab/feature",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
|
||||
@@ -461,6 +512,5 @@ describe("buildProjects", () => {
|
||||
expect(summary?.hosts).toHaveLength(1);
|
||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface ProjectSummary {
|
||||
hostCount: number;
|
||||
onlineHostCount: number;
|
||||
githubUrl?: string;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
export interface ProjectHost {
|
||||
@@ -43,7 +42,6 @@ export interface BuildProjectsInput {
|
||||
|
||||
export interface BuildProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
@@ -61,10 +59,6 @@ interface ProjectGroup {
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
function isSupportedProjectKey(projectKey: string): boolean {
|
||||
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
|
||||
}
|
||||
|
||||
function deriveGithubUrl(projectKey: string): string | undefined {
|
||||
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
|
||||
if (!match) {
|
||||
@@ -118,38 +112,27 @@ function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
|
||||
return left.serverId.localeCompare(right.serverId);
|
||||
}
|
||||
|
||||
function toProjectSummary(input: {
|
||||
draft: ProjectGroup;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}): ProjectSummary {
|
||||
const hosts = Array.from(input.draft.hostsByServerId.values())
|
||||
.map(toHostEntry)
|
||||
.sort(compareHosts);
|
||||
function toProjectSummary(draft: ProjectGroup): ProjectSummary {
|
||||
const hosts = Array.from(draft.hostsByServerId.values()).map(toHostEntry).sort(compareHosts);
|
||||
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
|
||||
return {
|
||||
projectKey: input.draft.projectKey,
|
||||
projectName: input.draft.projectName,
|
||||
projectKey: draft.projectKey,
|
||||
projectName: draft.projectName,
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: deriveGithubUrl(input.draft.projectKey),
|
||||
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
|
||||
githubUrl: deriveGithubUrl(draft.projectKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
let hiddenUnsupportedRemoteCount = 0;
|
||||
|
||||
for (const host of input.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
const projectKey = workspace.projectId;
|
||||
if (!isSupportedProjectKey(projectKey)) {
|
||||
hiddenUnsupportedRemoteCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = groups.get(projectKey);
|
||||
if (!group) {
|
||||
@@ -175,9 +158,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
}
|
||||
}
|
||||
|
||||
const projects = Array.from(groups.values()).map((draft) =>
|
||||
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
|
||||
);
|
||||
const projects = Array.from(groups.values()).map(toProjectSummary);
|
||||
projects.sort((left, right) => {
|
||||
const name = left.projectName.localeCompare(right.projectName);
|
||||
if (name !== 0) {
|
||||
@@ -186,8 +167,5 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
return left.projectKey.localeCompare(right.projectKey);
|
||||
});
|
||||
|
||||
return {
|
||||
projects,
|
||||
hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
return { projects };
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,8 +38,8 @@ function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i]!;
|
||||
const hi = pcm[i + 1]!;
|
||||
const lo = pcm[i];
|
||||
const hi = pcm[i + 1];
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
|
||||
@@ -39,7 +39,7 @@ function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: num
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0]! * (1 - frac) + input[i1]! * frac;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
output[i] = floatToInt16(sample);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ function pcm16LeToAudioBuffer(
|
||||
const audioBuffer = context.createBuffer(1, sampleCount, sampleRate);
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
for (let i = 0; i < sampleCount; i += 1) {
|
||||
const lo = bytes[i * 2]!;
|
||||
const hi = bytes[i * 2 + 1]!;
|
||||
const lo = bytes[i * 2];
|
||||
const hi = bytes[i * 2 + 1];
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value -= 0x10000;
|
||||
@@ -77,8 +77,8 @@ function pcm16LeToAudioBuffer(
|
||||
|
||||
async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> {
|
||||
const maybePromise = context.decodeAudioData(buffer.slice(0));
|
||||
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.slice(0), resolve, reject);
|
||||
@@ -323,7 +323,7 @@ export function createAudioEngine(
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const sample = input[i]!;
|
||||
const sample = input[i];
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
|
||||
|
||||
@@ -295,7 +295,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
function activateNextPlaybackGroup(): void {
|
||||
while (playback.orderedGroupIds.length > 0) {
|
||||
const groupId = playback.orderedGroupIds[0]!;
|
||||
const groupId = playback.orderedGroupIds[0];
|
||||
if (playback.groups.has(groupId)) {
|
||||
playback.activeGroupId = groupId;
|
||||
return;
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function runArchiveCommand(
|
||||
options: AgentArchiveOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentArchiveCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
@@ -58,7 +58,7 @@ export async function runArchiveCommand(
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function runAttachCommand(
|
||||
options: AgentAttachOptions,
|
||||
_command: Command,
|
||||
): Promise<void> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id) {
|
||||
console.error("Error: Agent ID required");
|
||||
@@ -115,7 +115,7 @@ export async function runAttachCommand(
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function runDeleteCommand(
|
||||
options: AgentDeleteOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentDeleteResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id && !options.all && !options.cwd) {
|
||||
const error: CommandError = {
|
||||
@@ -52,7 +52,7 @@ export async function runDeleteCommand(
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function runImportCommand(
|
||||
options: AgentImportOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentImportCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const sessionId = sessionIdArg.trim();
|
||||
if (!sessionId) {
|
||||
throw {
|
||||
@@ -134,7 +134,7 @@ export async function runImportCommand(
|
||||
}
|
||||
|
||||
const labels = parseImportLabels(options.label);
|
||||
const client = await connectToDaemonOrThrow(options.host as string | undefined, host);
|
||||
const client = await connectToDaemonOrThrow(options.host, host);
|
||||
|
||||
try {
|
||||
const agent = await client.importAgent({
|
||||
|
||||
@@ -216,7 +216,7 @@ export async function runInspectCommand(
|
||||
options: AgentInspectOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentInspectResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
@@ -230,7 +230,7 @@ export async function runInspectCommand(
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function runLogsCommand(
|
||||
options: AgentLogsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLogsResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id) {
|
||||
console.error("Error: Agent ID required");
|
||||
@@ -96,7 +96,7 @@ export async function runLogsCommand(
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
|
||||
|
||||
@@ -168,11 +168,11 @@ export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function runModeCommand(
|
||||
_command: Command,
|
||||
): Promise<AgentModeResult> {
|
||||
const normalizedMode = mode?.trim();
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!options.list && !normalizedMode) {
|
||||
@@ -64,7 +64,7 @@ export async function runModeCommand(
|
||||
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function runReloadCommand(
|
||||
options: AgentReloadOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentReloadCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
@@ -53,7 +53,7 @@ export async function runReloadCommand(
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -358,7 +358,7 @@ export async function runRunCommand(
|
||||
options: AgentRunOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentRunResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
|
||||
|
||||
validateRunOptions(prompt, options, outputSchema);
|
||||
@@ -367,7 +367,7 @@ export async function runRunCommand(
|
||||
const resolvedProviderModel = resolveProviderAndModel(options);
|
||||
const resolvedTitle = options.title ?? options.name;
|
||||
|
||||
const client = await connectToDaemonOrThrow(options.host as string | undefined, host);
|
||||
const client = await connectToDaemonOrThrow(options.host, host);
|
||||
|
||||
try {
|
||||
// Resolve working directory
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user