mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa1dc3ac21 | ||
|
|
00659bc32d | ||
|
|
6c11a082c5 | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'app-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -44,3 +44,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.29 - 2026-03-18
|
||||
|
||||
### Improved
|
||||
- Improved `paseo daemon status` with provider binary resolution and version checks for Claude, Codex, and OpenCode.
|
||||
|
||||
### Fixed
|
||||
- Fixed desktop-managed agent startup failures caused by the daemon using the GUI process PATH instead of the user's login shell PATH.
|
||||
|
||||
## 0.1.28 - 2026-03-15
|
||||
|
||||
### Added
|
||||
- Added OpenCode build and plan modes.
|
||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||
|
||||
### Improved
|
||||
- Improved the git action menu for more reliable repository actions.
|
||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||
- Updated the website hero copy and added a sponsor callout section.
|
||||
|
||||
### Fixed
|
||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -12,19 +12,27 @@
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
@@ -57,4 +65,4 @@ npm run typecheck
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ From repo root:
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clean # Clean native project
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clean
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
Generated
+3815
-43
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.29",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -28,6 +29,7 @@
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
@@ -59,6 +61,8 @@
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +146,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectDaemonClient,
|
||||
createReplyTurn,
|
||||
expectDetachedFromBottom,
|
||||
expectNearBottom,
|
||||
getChatContainerKey,
|
||||
readScrollMetrics,
|
||||
scrollUpFromBottom,
|
||||
seedBottomAnchorAgent,
|
||||
waitForAgentReady,
|
||||
waitForContentGrowth,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
|
||||
test.describe.configure({ timeout: 180000 });
|
||||
|
||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-direct-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-switch-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.getByTestId("sidebar-new-agent").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
||||
turnCount: 10,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1320, height: 920 });
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.click();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
||||
if (index < 5) {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
}
|
||||
}
|
||||
await expectNearBottom(page);
|
||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 760 });
|
||||
await expectNearBottom(page);
|
||||
|
||||
await scrollUpFromBottom(page, 720);
|
||||
await expectDetachedFromBottom(page);
|
||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
||||
|
||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
||||
const finish = await client.waitForFinish(agent.id, 120000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectDetachedFromBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
});
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.setViewportSize({ width: 780, height: 720 });
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SERVER_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
||||
const AGENT_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
||||
const DAEMON_ENDPOINT =
|
||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
test.use({ browserName: "firefox" });
|
||||
|
||||
function seedDaemonRegistryScript(params: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const daemon = {
|
||||
serverId: params.serverId,
|
||||
label: "localhost",
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${params.endpoint}`,
|
||||
type: "direct",
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${params.endpoint}`,
|
||||
createdAt: params.nowIso,
|
||||
updatedAt: params.nowIso,
|
||||
};
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
serverId: params.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollUpFromBottom(
|
||||
page: Page,
|
||||
pixels: number
|
||||
): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [
|
||||
rootElement,
|
||||
...Array.from(rootElement.querySelectorAll("*")),
|
||||
];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toContain("Firefox");
|
||||
|
||||
await page.addInitScript(seedDaemonRegistryScript, {
|
||||
serverId: SERVER_ID,
|
||||
endpoint: DAEMON_ENDPOINT,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Require enough history so the repro actually scrolls away from bottom.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
||||
})
|
||||
.toBeGreaterThan(300);
|
||||
|
||||
await scrollUpFromBottom(page, 900);
|
||||
await page.waitForTimeout(250);
|
||||
const beforeSubmit = await readScrollMetrics(page);
|
||||
const beforePath = testInfo.outputPath("before-submit.png");
|
||||
await page.screenshot({ path: beforePath, fullPage: true });
|
||||
await testInfo.attach("before-submit", {
|
||||
path: beforePath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const afterSubmit = await readScrollMetrics(page);
|
||||
const afterPath = testInfo.outputPath("after-submit.png");
|
||||
await page.screenshot({ path: afterPath, fullPage: true });
|
||||
await testInfo.attach("after-submit", {
|
||||
path: afterPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("firefox-scroll-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
url: AGENT_URL,
|
||||
submitText: SUBMIT_TEXT,
|
||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
||||
beforeScreenshot: beforePath,
|
||||
afterScreenshot: afterPath,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[firefox-scroll-repro] ${JSON.stringify(
|
||||
{
|
||||
submitText: SUBMIT_TEXT,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
||||
NEAR_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
const menu = page.getByTestId('changes-diff-status-menu');
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const optionTestId =
|
||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
||||
await page.getByTestId(optionTestId).click({ force: true });
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
const changesScope = getChangesScope(page);
|
||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(branchName);
|
||||
}
|
||||
|
||||
const preferredOption = menu
|
||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
||||
.first();
|
||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
||||
await preferredOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (/Create new worktree/i.test(currentValue)) {
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await createOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
await selectChangesView(page, 'base');
|
||||
await selectChangesView(page, 'working');
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
if (value.startsWith('/var/')) {
|
||||
return `/private${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
worktreePath: string;
|
||||
branchRef: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
||||
const blocks = raw
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
for (const block of blocks) {
|
||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
||||
if (!worktreeMatch) {
|
||||
continue;
|
||||
}
|
||||
const branchMatch = block.match(/^branch (.+)$/m);
|
||||
entries.push({
|
||||
worktreePath: worktreeMatch[1].trim(),
|
||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const entries = parseGitWorktreeList(output);
|
||||
const candidate = entries.find((entry) => {
|
||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.branchRef) {
|
||||
return false;
|
||||
}
|
||||
return !/\/main$/i.test(entry.branchRef);
|
||||
});
|
||||
|
||||
if (candidate) {
|
||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
||||
if (branchName) {
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient git worktree read errors while polling.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
]);
|
||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
|
||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgentInRepo } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
||||
|
||||
// Verify the assistant response is rendered.
|
||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
openSettings,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from '@/utils/host-routes';
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
active: 0,
|
||||
getUserMediaCalls: 0,
|
||||
stopCalls: 0,
|
||||
lastRecorder: null as null | { state: string },
|
||||
};
|
||||
(window as any).__mic = mic;
|
||||
|
||||
(window as any).isSecureContext = true;
|
||||
|
||||
const nav = navigator as any;
|
||||
if (!nav.mediaDevices) {
|
||||
nav.mediaDevices = {};
|
||||
}
|
||||
nav.mediaDevices.getUserMedia = async () => {
|
||||
mic.getUserMediaCalls += 1;
|
||||
mic.active += 1;
|
||||
const track = {
|
||||
stop: () => {
|
||||
mic.stopCalls += 1;
|
||||
mic.active = Math.max(0, mic.active - 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getTracks: () => [track],
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
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: mimeType });
|
||||
};
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
public static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public state: 'inactive' | 'recording' = 'inactive';
|
||||
public mimeType: string;
|
||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
public onerror: ((event: unknown) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||
mic.lastRecorder = this;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('Not recording');
|
||||
}
|
||||
this.state = 'inactive';
|
||||
try {
|
||||
this.ondataavailable?.({
|
||||
data: blobFromBase64(base64Audio, mimeType),
|
||||
});
|
||||
} catch (err) {
|
||||
this.onerror?.(err);
|
||||
}
|
||||
this.dispatchEvent(new Event('stop'));
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
async function expectComposerReady(page: Page) {
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
||||
}
|
||||
|
||||
async function expectDictationStarted(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
async function expectDictationStopped(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await openSettings(page);
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStopped(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
if (mic.lastRecorder) {
|
||||
mic.lastRecorder.state = 'inactive';
|
||||
}
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expectComposerReady(page);
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
||||
);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
const toggle = page
|
||||
.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
})
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page
|
||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
||||
.first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
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({
|
||||
@@ -58,31 +62,12 @@ test.beforeEach(async ({ page }) => {
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
||||
}
|
||||
const testDaemon = {
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const createAgentPreferences = {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(repo.path, 'README.md');
|
||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
||||
await appendFile(readmePath, `\n${lines}`);
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
||||
await fileToggle.click();
|
||||
|
||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
||||
await expect(scroll).toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
||||
}).toBe(true);
|
||||
|
||||
await scroll.hover();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel(0, 700);
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(fileToggle).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
||||
const prompt = `global draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
||||
|
||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
||||
const url = new URL(rawUrl)
|
||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
||||
const workspaceToken = pathMatch?.[1] ?? null
|
||||
let agentId = pathMatch?.[2] ?? null
|
||||
|
||||
if (!agentId) {
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
if (openMatch?.[1]) {
|
||||
agentId = openMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return { workspaceToken, agentId }
|
||||
}
|
||||
|
||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
||||
const url = new URL(rawUrl)
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
return openMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
await thinkingTrigger.click({ force: true })
|
||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
|
||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
||||
for (const label of preferred) {
|
||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true })
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = menu.getByRole('button')
|
||||
const count = await options.count()
|
||||
if (count > 0) {
|
||||
await options.last().click({ force: true })
|
||||
}
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
||||
const count = await candidates.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
||||
if (await isAnyStopVisible(page)) {
|
||||
return true
|
||||
}
|
||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
||||
const count = await interruptSendButton.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
||||
const locator = page.getByText(matcher).first()
|
||||
const matches = page.getByText(matcher)
|
||||
const count = await matches.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return await locator.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(360_000)
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
||||
const id = `${Date.now()}`
|
||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
||||
const seedToken = `ACK_SEED_${id}`
|
||||
const secondToken = `ACK_SECOND_${id}`
|
||||
expect(seedPrompt).not.toBe(secondPrompt)
|
||||
expect(seedToken).not.toBe(secondToken)
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 1) Seed workspace with an existing agent.
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await seedComposer.press('Enter')
|
||||
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const seededAgentId = seededContext.agentId
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 2) Use global New Agent entry and 3) select same workspace.
|
||||
await page.getByText('New agent', { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
await preferSlowerThinkingOption(page)
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
|
||||
// 4) Create second agent from global draft.
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(secondPrompt)
|
||||
await composer.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
if (page.url().includes('/new-agent')) {
|
||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
||||
await sendButton.click({ force: true })
|
||||
}
|
||||
|
||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
||||
if (!createdContext.agentId) {
|
||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const createdAgentId = createdContext.agentId
|
||||
expect(createdAgentId).toBeTruthy()
|
||||
expect(createdAgentId).not.toBe(seededAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
||||
|
||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
||||
return finalTabIds.includes(createdTabTestId)
|
||||
})
|
||||
.toBe(true)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
|
||||
// 6) Response assertions are scoped to created-agent active context.
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await lifecycleComposer.fill(lifecyclePrompt)
|
||||
await lifecycleComposer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
let sawRunning = false
|
||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
||||
if (await isRunningControlVisible(page)) {
|
||||
sawRunning = true
|
||||
break
|
||||
}
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
expect(sawRunning).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -199,6 +199,19 @@ function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
|
||||
if (existsSync(relayDistEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[e2e] Building @getpaseo/relay for daemon startup');
|
||||
execSync('npm run build --workspace=@getpaseo/relay', {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const idx = url.indexOf(marker);
|
||||
@@ -217,6 +230,7 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
|
||||
@@ -66,17 +66,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string): string {
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label),
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string): string {
|
||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
@@ -124,9 +124,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
@@ -134,7 +136,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
@@ -143,11 +145,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
@@ -165,16 +167,29 @@ export async function seedBottomAnchorAgent(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isEditable =
|
||||
tagName === "textarea" ||
|
||||
tagName === "input" ||
|
||||
element.getAttribute("contenteditable") === "true";
|
||||
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||
});
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight -
|
||||
right.clientHeight -
|
||||
(left.scrollHeight - left.clientHeight)
|
||||
)[0] ?? (root as HTMLElement);
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
@@ -194,29 +209,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
@@ -263,9 +282,7 @@ export async function waitForContentGrowth(
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.evaluate((element) => {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './daemon-registry';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -33,8 +37,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
||||
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -45,42 +49,20 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem(
|
||||
'@paseo:daemon-registry',
|
||||
JSON.stringify([
|
||||
{
|
||||
serverId: expectedServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${expectedEndpoint}`,
|
||||
type: 'direct',
|
||||
endpoint: expectedEndpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
])
|
||||
);
|
||||
localStorage.setItem(
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ expectedEndpoint, nowIso, expectedServerId }
|
||||
{ daemon, preferences }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
@@ -128,13 +110,13 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
||||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
}
|
||||
|
||||
@@ -154,14 +136,36 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
||||
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
|
||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
||||
await newAgentButton.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeededHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const connection = buildDirectTcpConnection(input.endpoint);
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? TEST_HOST_LABEL,
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const seededServerId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!seededServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const extraPort = Number(daemonPort) + 1;
|
||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const extraDaemon = {
|
||||
serverId: 'srv_e2e_extra_daemon',
|
||||
label: 'extra',
|
||||
connections: [
|
||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
||||
],
|
||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seededTestDaemon = {
|
||||
serverId: seededServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||
await page.addInitScript(
|
||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||
if (localStorage.getItem(seedOnceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
let parsed: any[] = [];
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
localStorage.setItem(seedOnceKey, '1');
|
||||
|
||||
const next = [...list];
|
||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
||||
if (!hasSeeded) {
|
||||
next.push(seededTestDaemon);
|
||||
}
|
||||
|
||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
||||
if (!alreadyPresent) {
|
||||
next.push(daemon);
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||
},
|
||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
extraDaemon.serverId,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||
|
||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await gotoHome(page);
|
||||
|
||||
// The selected host should be restored after a full reload without manual selection.
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Ensure this test's storage is deterministic even under parallel load.
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
||||
await newAgentButton.click();
|
||||
} else {
|
||||
await page.getByText('New agent', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await gotoHome(page);
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
return (
|
||||
entry?.serverId === serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.some(
|
||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ port: daemonPort, serverId },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
function encodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
const relayId = `relay:${expected.relay.endpoint}`;
|
||||
return (
|
||||
entry?.serverId === expected.serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.length === 1 &&
|
||||
entry.connections[0]?.id === relayId &&
|
||||
entry.connections[0]?.type === 'relay' &&
|
||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: offer },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await input.focus();
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
pasted: false,
|
||||
elementTag: active ? active.tagName : null,
|
||||
defaultPrevented: false,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
active.dispatchEvent(event);
|
||||
|
||||
return {
|
||||
pasted: true,
|
||||
elementTag: active.tagName,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.pasted).toBe(true);
|
||||
expect(result.defaultPrevented).toBe(true);
|
||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgentWithConfig,
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
function buildWriteCommand(filePath: string): string {
|
||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
||||
}
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
await expect
|
||||
.poll(() => existsSync(filePath), {
|
||||
message: `File ${filePath} should exist after allowing permission`,
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// After allowing, the file should be created successfully
|
||||
// The tool call count might still be 1 if the UI updates quickly
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('deny permission does not create the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
||||
|
||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
||||
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, nonExistentDir);
|
||||
|
||||
// Enter prompt text
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(promptText);
|
||||
|
||||
// Try to submit - this should fail with an error about the directory not existing
|
||||
await input.press('Enter');
|
||||
|
||||
// Verify error message is displayed (error includes the path)
|
||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
||||
|
||||
// Verify the prompt text is still preserved in the input
|
||||
await expect(input).toHaveValue(promptText);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
// Should eventually connect through the relay connection.
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Use relay by making the direct endpoint intentionally fail.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
||||
const page2 = await page.context().newPage();
|
||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
});
|
||||
await page2.goto('/');
|
||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
||||
await settingsButton2.click();
|
||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Stability window: keep both tabs open and ensure they remain online.
|
||||
await page.waitForTimeout(30_000);
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
const repoA = await createTempGitRepo();
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.ceil((p / 100) * sorted.length);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
avgMs: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: Math.min(...values),
|
||||
maxMs: Math.max(...values),
|
||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
||||
p95Ms: percentile(values, 95),
|
||||
p99Ms: percentile(values, 99),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStressCommand(doneMarker: string): string {
|
||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
||||
return [
|
||||
"i=1",
|
||||
"printf '\\033[?1049h\\033[2J'",
|
||||
"while [ $i -le 240 ]; do",
|
||||
"printf '\\033[H'",
|
||||
"r=1",
|
||||
"while [ $r -le 24 ]; do",
|
||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
||||
"r=$((r+1))",
|
||||
"done",
|
||||
"sleep 0.01",
|
||||
"i=$((i+1))",
|
||||
"done",
|
||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
const expected = currentlyExpanded ? "false" : "true";
|
||||
|
||||
const start = Date.now();
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
||||
await newTerminalButton.click();
|
||||
|
||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const monitor = {
|
||||
samples: [] as number[],
|
||||
active: true,
|
||||
rafId: 0,
|
||||
lastTs: performance.now(),
|
||||
};
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!monitor.active) {
|
||||
return;
|
||||
}
|
||||
monitor.samples.push(ts - monitor.lastTs);
|
||||
monitor.lastTs = ts;
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
|
||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
||||
{
|
||||
stop: () => {
|
||||
if (!monitor.active) {
|
||||
return { samples: monitor.samples };
|
||||
}
|
||||
monitor.active = false;
|
||||
cancelAnimationFrame(monitor.rafId);
|
||||
return { samples: monitor.samples };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
||||
const stressCommand = buildStressCommand(doneMarker);
|
||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const interactionLatenciesMs: number[] = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
||||
interactionLatenciesMs.push(latency);
|
||||
}
|
||||
|
||||
const rafResult = await page.evaluate(() => {
|
||||
const handle = (
|
||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
||||
).__PASEO_E2E_RAF_MONITOR__;
|
||||
if (!handle || typeof handle.stop !== "function") {
|
||||
return { samples: [] as number[] };
|
||||
}
|
||||
return handle.stop();
|
||||
});
|
||||
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
const report = {
|
||||
mode: "report-only",
|
||||
profile: "single-stress",
|
||||
generatedAt: new Date().toISOString(),
|
||||
workload: {
|
||||
frames: 240,
|
||||
rowsPerFrame: 24,
|
||||
frameSleepMs: 10,
|
||||
doneMarker,
|
||||
postMarker,
|
||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
||||
},
|
||||
frameGapMs: {
|
||||
...summarize(frameGapsMs),
|
||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs'
|
||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
||||
const seedPrompt = `seed workspace ${Date.now()}`
|
||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
|
||||
// Force the setup onto the global draft surface. In slow runs the app can
|
||||
// still be on a previously opened agent/workspace view where the placement
|
||||
// form is not rendered.
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
||||
expect(workspaceRouteToken).toBeTruthy()
|
||||
await waitForWorkspaceTabsVisible(page)
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
||||
if (!draftTabTestId) {
|
||||
throw new Error('Expected a draft workspace tab to be created.')
|
||||
}
|
||||
|
||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(createPrompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page)
|
||||
expect(finalIds).toContain(draftTabTestId)
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
|
||||
const currentUrl = page.url()
|
||||
if (!workspaceRouteToken) {
|
||||
throw new Error('Expected workspace route token to be present.')
|
||||
}
|
||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
sampleWorkspaceTabIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function expectComposerFocused(page: Page) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await composer.evaluate(
|
||||
(element) => document.activeElement === element
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
||||
expect(draftTabTestId).toBeTruthy();
|
||||
|
||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill(createPrompt);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const snapshots = await samplingPromise;
|
||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page);
|
||||
expect(finalIds.length).toBe(withDraftIds.length);
|
||||
expect(finalIds).toContain(draftTabTestId!);
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
||||
(id) => !beforeSecondAgentIds.includes(id)
|
||||
);
|
||||
if (!secondAgentTabTestId) {
|
||||
throw new Error("Expected second agent tab to be created.");
|
||||
}
|
||||
|
||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
||||
if (!firstAgentTabTestId) {
|
||||
throw new Error("Expected first agent tab to exist.");
|
||||
}
|
||||
|
||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: workspacePath,
|
||||
prompt: `workspace header restore ${Date.now()}`,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
const initialTabCount = await workspaceTabs.count();
|
||||
const targetTabCount = initialTabCount + 8;
|
||||
|
||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
||||
await terminalButton.click();
|
||||
await expect
|
||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(attempt + 1);
|
||||
}
|
||||
|
||||
const agentBoundsBefore = await agentButton.boundingBox();
|
||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
||||
await tabsScroll.evaluate((el) => {
|
||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const agentBoundsAfter = await agentButton.boundingBox();
|
||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
||||
|
||||
expect(agentBoundsAfter).not.toBeNull();
|
||||
expect(terminalBoundsAfter).not.toBeNull();
|
||||
|
||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
||||
const agentBounds = await agentButton.boundingBox();
|
||||
|
||||
expect(lastTabBounds).not.toBeNull();
|
||||
expect(agentBounds).not.toBeNull();
|
||||
|
||||
if (!lastTabBounds || !agentBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
||||
|
||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (initiallyExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { test } from './fixtures';
|
||||
import { setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from './helpers/workspace-ui';
|
||||
|
||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
||||
|
||||
const tokenA = `SYNC_A_${Date.now()}`;
|
||||
const tokenB = `SYNC_B_${Date.now()}`;
|
||||
|
||||
try {
|
||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await seedWorkspaceActivity(page, tokenA);
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoB.path);
|
||||
await seedWorkspaceActivity(page, tokenB);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-b-branch',
|
||||
subtitle: workspaceLabelFromPath(repoB.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
await repoB.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.29",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "npm run android:development",
|
||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
@@ -24,16 +23,16 @@
|
||||
"build": "npm run build:web",
|
||||
"build:web": "expo export --platform web",
|
||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.29",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"@getpaseo/server": "0.1.29",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
@@ -469,7 +470,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const shouldShowAppChrome = activeServerId !== null;
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from './index-startup'
|
||||
} from '@/app-support/index-startup'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
|
||||
|
||||
describe("attachment store", () => {
|
||||
afterEach(() => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("creates the default web attachment store without runtime module resolution errors", async () => {
|
||||
const store = await getAttachmentStore();
|
||||
|
||||
expect(store.storageType).toBe("web-indexeddb");
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,20 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = require(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = require(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
"./web/indexeddb-attachment-store"
|
||||
);
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = require(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
"./native/native-file-attachment-store"
|
||||
);
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -155,7 +156,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePath(target.path, agent.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -314,6 +315,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -656,102 +658,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
});
|
||||
|
||||
function normalizeInlinePath(
|
||||
rawPath: string,
|
||||
cwd?: string
|
||||
): { directory: string; file?: string } | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = normalizePathInput(rawPath);
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = normalizedInput;
|
||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||
if (cwdRelative) {
|
||||
normalized = cwdRelative;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2) || ".";
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
normalized = ".";
|
||||
}
|
||||
|
||||
if (normalized === ".") {
|
||||
return { directory: "." };
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
const dir = normalized.replace(/\/+$/, "");
|
||||
return { directory: dir.length > 0 ? dir : "." };
|
||||
}
|
||||
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
|
||||
return {
|
||||
directory: directory.length > 0 ? directory : ".",
|
||||
file: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (
|
||||
!normalizedCwd ||
|
||||
!isAbsolutePath(pathValue) ||
|
||||
!isAbsolutePath(normalizedCwd)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||
const comparePrefix = normalizePathForCompare(prefix);
|
||||
|
||||
if (comparePath === compareCwd) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (comparePath.startsWith(comparePrefix)) {
|
||||
return pathValue.slice(prefix.length) || ".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||
}
|
||||
|
||||
function WorkingIndicator() {
|
||||
const dotOne = useSharedValue(0);
|
||||
const dotTwo = useSharedValue(0);
|
||||
|
||||
@@ -69,7 +69,6 @@ export function DictationControls({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
@@ -177,7 +176,6 @@ export function DictationOverlay({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
describe("git-action-visibility", () => {
|
||||
describe("shouldShowMergeFromBaseAction", () => {
|
||||
it("shows on non-base branches", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: false,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides on base branch when no remote exists", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides on base branch when local is in sync with origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows on base branch when ahead of origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 1,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows on base branch when behind origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 2,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
export function shouldShowMergeFromBaseAction(input: {
|
||||
isOnBaseBranch: boolean;
|
||||
hasRemote: boolean;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
}): boolean {
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||
|
||||
function createInput(
|
||||
overrides: Partial<BuildGitActionsInput> = {}
|
||||
): BuildGitActionsInput {
|
||||
return {
|
||||
isGit: true,
|
||||
githubFeaturesEnabled: true,
|
||||
hasPullRequest: false,
|
||||
pullRequestUrl: null,
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
aheadCount: 0,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
shouldPromoteArchive: false,
|
||||
shipDefault: "merge",
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
push: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
pr: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("git-actions-policy", () => {
|
||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
||||
const noPrActions = buildGitActions(createInput());
|
||||
const withPrActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/123",
|
||||
aheadCount: 3,
|
||||
aheadOfOrigin: 2,
|
||||
shipDefault: "pr",
|
||||
})
|
||||
);
|
||||
|
||||
expect(noPrActions.primary).toBeNull();
|
||||
expect(withPrActions.primary?.id).toBe("push");
|
||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables hidden-before actions with explanations instead", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
||||
|
||||
expect(actionById.get("push")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.get("pr")).toMatchObject({
|
||||
label: "Create PR",
|
||||
disabled: true,
|
||||
description: "Branch has no commits ahead of main",
|
||||
});
|
||||
expect(actionById.get("merge-branch")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No commits to merge into main",
|
||||
});
|
||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.has("archive-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the current primary action visible in the menu", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
})
|
||||
);
|
||||
|
||||
expect(actions.primary?.id).toBe("pr");
|
||||
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
|
||||
});
|
||||
|
||||
it("disables sync on the base branch when already up to date", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
})
|
||||
);
|
||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||
|
||||
expect(syncAction).toMatchObject({
|
||||
label: "Sync",
|
||||
disabled: true,
|
||||
description: "Already up to date",
|
||||
});
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
|
||||
interface GitActionRuntimeState {
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
shouldPromoteArchive: boolean;
|
||||
shipDefault: "merge" | "pr";
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: input.runtime.commit.disabled,
|
||||
status: input.runtime.commit.status,
|
||||
icon: input.runtime.commit.icon,
|
||||
handler: input.runtime.commit.handler,
|
||||
});
|
||||
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
||||
status: input.runtime.push.status,
|
||||
description: input.hasRemote ? undefined : "No remote configured",
|
||||
icon: input.runtime.push.icon,
|
||||
handler: input.runtime.push.handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${input.baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled:
|
||||
input.runtime["merge-branch"].disabled ||
|
||||
!input.baseRefAvailable ||
|
||||
input.hasUncommittedChanges ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime["merge-branch"].status,
|
||||
description: getMergeBranchDescription(input),
|
||||
icon: input.runtime["merge-branch"].icon,
|
||||
handler: input.runtime["merge-branch"].handler,
|
||||
});
|
||||
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
||||
status: input.runtime["merge-from-base"].status,
|
||||
description: getMergeFromBaseDescription(input),
|
||||
icon: input.runtime["merge-from-base"].icon,
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondary.push(allActions.get("archive-worktree")!);
|
||||
}
|
||||
|
||||
return { primary, secondary, menu: [] };
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
}
|
||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
||||
return "push";
|
||||
}
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
if (
|
||||
input.isOnBaseBranch &&
|
||||
input.hasRemote &&
|
||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
||||
) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (input.aheadCount > 0) {
|
||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||
return {
|
||||
id: "pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
||||
status: input.runtime.pr.status,
|
||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled:
|
||||
input.runtime.pr.disabled ||
|
||||
!input.githubFeaturesEnabled ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime.pr.status,
|
||||
description: getCreatePrDescription(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
|
||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.githubFeaturesEnabled) {
|
||||
return "GitHub features unavailable";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `No commits to merge into ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return "No remote configured";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
||||
return "Already up to date";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||
closeOnSelect={
|
||||
action.status === "idle" &&
|
||||
action.id === "pr" &&
|
||||
action.label === "View PR"
|
||||
}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
|
||||
@@ -47,19 +47,19 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
buildGitActions,
|
||||
type GitActions,
|
||||
} from "@/components/git-actions-policy";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
|
||||
// Re-export types from shared hook
|
||||
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
@@ -790,19 +790,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
actionsDisabled || archiveStatus === "pending";
|
||||
|
||||
let bodyContent: ReactElement;
|
||||
|
||||
@@ -892,184 +886,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
// ==========================================================================
|
||||
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
if (!isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
// Build all possible actions
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
// Commit - always available
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
},
|
||||
pr: {
|
||||
disabled: prDisabled,
|
||||
status: hasPullRequest ? "idle" : prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
},
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Push - when has remote
|
||||
if (hasRemote) {
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => openURLInNewTab(prUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Create PR - when ahead of base and no PR
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge branch - when ahead of base
|
||||
if (aheadCount > 0) {
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Update/sync from base
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
// Archive worktree - only for Paseo worktrees
|
||||
if (isPaseoOwnedWorktree) {
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
}
|
||||
// Rule 3: Has PR → View PR
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
|
||||
const preferredAction = allActions.get(preferred);
|
||||
const fallbackAction = allActions.get(fallback);
|
||||
|
||||
if (preferredAction && !preferredAction.disabled) {
|
||||
primaryActionId = preferred;
|
||||
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||
primaryActionId = fallback;
|
||||
} else if (preferredAction) {
|
||||
primaryActionId = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
|
||||
@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
|
||||
interface SourceControlPanelIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function SourceControlPanelIcon({
|
||||
size = 16,
|
||||
color = "currentColor",
|
||||
strokeWidth = 2,
|
||||
}: SourceControlPanelIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Plus */}
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
{/* Minus */}
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
parseFileProtocolUrl,
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
type InlinePathTarget,
|
||||
} from "@/utils/inline-path";
|
||||
@@ -429,6 +429,7 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
workspaceRoot?: string;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
@@ -744,6 +745,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -770,7 +772,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
const fileTarget = onInlinePathPress
|
||||
? parseFileProtocolUrl(url)
|
||||
? parseAssistantFileLink(url, { workspaceRoot })
|
||||
: null;
|
||||
if (fileTarget) {
|
||||
onInlinePathPress?.(fileTarget);
|
||||
@@ -781,7 +783,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, [onInlinePathPress]);
|
||||
}, [onInlinePathPress, workspaceRoot]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -22,14 +22,13 @@ export function RealtimeVoiceOverlay({
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
@@ -109,6 +109,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
stackBehavior="replace"
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
|
||||
@@ -23,6 +23,7 @@ type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onValueChange: (value: T) => void;
|
||||
size?: SegmentedControlSize;
|
||||
hideLabels?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onValueChange,
|
||||
size = "md",
|
||||
hideLabels = false,
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
@@ -72,12 +74,14 @@ export function SegmentedControl<T extends string>({
|
||||
{option.icon({ color: iconColor, size: iconSize })}
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{hideLabels ? null : (
|
||||
<Text
|
||||
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
|
||||
export function VoiceCompactIndicator() {
|
||||
const { theme } = useUnistyles();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
isVoiceMode,
|
||||
isVoiceSwitching,
|
||||
@@ -24,7 +24,6 @@ export function VoiceCompactIndicator() {
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useHosts } from "@/runtime/host-runtime";
|
||||
export function VoicePanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
isMuted,
|
||||
stopVoice,
|
||||
@@ -27,7 +27,6 @@ export function VoicePanel() {
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
interface VolumeMeterProps {
|
||||
volume: number;
|
||||
isMuted?: boolean;
|
||||
isDetecting?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
variant?: "default" | "compact";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -12,12 +13,23 @@ import { NameHostModal } from "./name-host-modal";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
label: string;
|
||||
testID: string;
|
||||
primary: boolean;
|
||||
icon: typeof QrCode;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[6],
|
||||
paddingBottom: 0,
|
||||
alignItems: "center",
|
||||
},
|
||||
content: {
|
||||
@@ -26,11 +38,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xl,
|
||||
@@ -61,8 +68,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
actionButtonPrimary: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
actionText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -70,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
actionTextPrimary: {
|
||||
color: theme.colors.palette.white,
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -86,6 +93,7 @@ export interface WelcomeScreenProps {
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { renameHost } = useHostMutations();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -111,51 +119,87 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
[router]
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] = Platform.OS === "web"
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
contentContainerStyle={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Add a host to start.</Text>
|
||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||
onPress={() => setIsDirectOpen(true)}
|
||||
testID="welcome-direct-connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => setIsPasteLinkOpen(true)}
|
||||
testID="welcome-paste-pairing-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Paste pairing link</Text>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan?source=onboarding")}
|
||||
testID="welcome-scan-qr"
|
||||
key={action.key}
|
||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||
onPress={action.onPress}
|
||||
testID={action.testID}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Scan QR code</Text>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
@@ -676,7 +676,7 @@ function SessionProviderInternal({
|
||||
return;
|
||||
}
|
||||
|
||||
return voiceRuntime.registerSession({
|
||||
return voiceRuntime.registerSession({
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
@@ -684,11 +684,17 @@ function SessionProviderInternal({
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType, isLast) => {
|
||||
sendVoiceAudioChunk: async (audioData, mimeType) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType, isLast);
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType);
|
||||
},
|
||||
audioPlayed: async (chunkId) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon unavailable");
|
||||
}
|
||||
await client.audioPlayed(chunkId);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
if (!client) {
|
||||
@@ -1236,6 +1242,11 @@ function SessionProviderInternal({
|
||||
}
|
||||
|
||||
const payload: AudioOutputPayload = message.payload;
|
||||
if (payload.isVoiceMode && voiceRuntime) {
|
||||
voiceRuntime.handleAudioOutput(serverId, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackGroupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
const isFinalChunk = payload.isLastChunk ?? true;
|
||||
@@ -1421,14 +1432,22 @@ function SessionProviderInternal({
|
||||
if (message.type !== "transcription_result") return;
|
||||
|
||||
const transcriptText = message.payload.text.trim();
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
if (!transcriptText) {
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentAssistantMessage(serverId, "");
|
||||
});
|
||||
|
||||
const unsubVoiceInputState = client.on("voice_input_state", (message) => {
|
||||
if (message.type !== "voice_input_state") return;
|
||||
voiceRuntime?.onServerSpeechStateChanged(
|
||||
serverId,
|
||||
message.payload.isSpeaking
|
||||
);
|
||||
});
|
||||
|
||||
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
|
||||
if (message.type !== "agent_deleted") {
|
||||
return;
|
||||
@@ -1537,6 +1556,7 @@ function SessionProviderInternal({
|
||||
unsubActivity();
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubVoiceInputState();
|
||||
unsubAgentDeleted();
|
||||
unsubAgentArchived();
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
|
||||
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||
volume: 0,
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
segmentDuration: 0,
|
||||
};
|
||||
|
||||
@@ -554,7 +554,7 @@ function PairingOfferDialogContent(input: {
|
||||
void QRCode.toDataURL(pairingOffer.url, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 320,
|
||||
width: 480,
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
@@ -606,17 +606,19 @@ function PairingOfferDialogContent(input: {
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
<View style={styles.linkSection}>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
@@ -717,18 +719,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
qrCard: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
minHeight: 220,
|
||||
minWidth: 220,
|
||||
padding: theme.spacing[4],
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
alignSelf: 'stretch',
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
qrImage: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
linkSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
linkLabel: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -4,37 +4,14 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility";
|
||||
import {
|
||||
buildGitActions,
|
||||
type GitActions,
|
||||
} from "@/components/git-actions-policy";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "view-pr"
|
||||
| "create-pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
@@ -252,19 +229,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
actionsDisabled || archiveStatus === "pending";
|
||||
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
@@ -275,163 +246,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
|
||||
// Build actions
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
if (!isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: icons.commit,
|
||||
handler: handleCommit,
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: icons.commit,
|
||||
handler: handleCommit,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
icon: icons.push,
|
||||
handler: handlePush,
|
||||
},
|
||||
pr: {
|
||||
disabled: prDisabled,
|
||||
status: hasPullRequest ? "idle" : prCreateStatus,
|
||||
icon: hasPullRequest ? icons.viewPr : icons.createPr,
|
||||
handler: () => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
},
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
icon: icons.merge,
|
||||
handler: handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (hasRemote) {
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
icon: icons.push,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
icon: icons.viewPr,
|
||||
handler: () => openURLInNewTab(prUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateStatus,
|
||||
icon: icons.createPr,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
if (aheadCount > 0) {
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
icon: icons.merge,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
if (isPaseoOwnedWorktree) {
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
} else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
} else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
} else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
} else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
} else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
|
||||
const preferredAction = allActions.get(preferred);
|
||||
const fallbackAction = allActions.get(fallback);
|
||||
|
||||
if (preferredAction && !preferredAction.disabled) {
|
||||
primaryActionId = preferred;
|
||||
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||
primaryActionId = fallback;
|
||||
} else if (preferredAction) {
|
||||
primaryActionId = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
|
||||
|
||||
const EMPTY_ORDER: string[] = []
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
||||
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {}
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor['status']
|
||||
|
||||
@@ -271,7 +272,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
|
||||
)
|
||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
||||
serverId ? state.workspaceOrderByServerAndProject : {}
|
||||
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE
|
||||
)
|
||||
|
||||
const isActive = Boolean(serverId)
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
ScrollView,
|
||||
Alert,
|
||||
Platform,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Buffer } from "buffer";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
@@ -43,6 +45,8 @@ import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -74,16 +78,16 @@ function formatActiveConnectionBadge(input: {
|
||||
return null;
|
||||
}
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
return { icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
if (activeConnection.type === "directSocket") {
|
||||
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
return { icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
}
|
||||
if (activeConnection.type === "directPipe") {
|
||||
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
return { icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
}
|
||||
@@ -226,6 +230,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
gap: 0,
|
||||
marginLeft: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
advancedTrigger: {
|
||||
flexDirection: "row",
|
||||
@@ -484,6 +490,7 @@ function DesktopAppUpdateRow() {
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
@@ -500,6 +507,8 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
|
||||
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
||||
const isLoading = settingsLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
@@ -659,6 +668,35 @@ export default function SettingsScreen() {
|
||||
const restartConfirmationMessage =
|
||||
"This will restart the daemon. The app will reconnect automatically.";
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaybackTestRunning(true);
|
||||
setPlaybackTestResult(null);
|
||||
|
||||
try {
|
||||
const bytes = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64");
|
||||
await voiceAudioEngine.initialize();
|
||||
voiceAudioEngine.stop();
|
||||
await voiceAudioEngine.play({
|
||||
type: "audio/pcm;rate=16000;bits=16",
|
||||
size: bytes.byteLength,
|
||||
async arrayBuffer() {
|
||||
return Uint8Array.from(bytes).buffer;
|
||||
},
|
||||
});
|
||||
setPlaybackTestResult(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Playback test failed", error);
|
||||
setPlaybackTestResult(`Playback failed: ${message}`);
|
||||
} finally {
|
||||
setIsPlaybackTestRunning(false);
|
||||
}
|
||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
@@ -842,6 +880,7 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
hideLabels={Platform.OS !== "web"}
|
||||
value={settings.theme}
|
||||
onValueChange={handleThemeChange}
|
||||
options={[
|
||||
@@ -869,6 +908,28 @@ export default function SettingsScreen() {
|
||||
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
|
||||
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Test audio</Text>
|
||||
{playbackTestResult ? (
|
||||
<Text style={styles.aboutHintText}>{playbackTestResult}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => void handlePlaybackTest()}
|
||||
disabled={!voiceAudioEngine || isPlaybackTestRunning}
|
||||
>
|
||||
{isPlaybackTestRunning ? "Playing..." : "Play test"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About */}
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>About</Text>
|
||||
@@ -1393,18 +1454,22 @@ function DaemonCard({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Pressable
|
||||
style={styles.hostSettingsButton}
|
||||
textStyle={{ fontSize: 0, lineHeight: 0 }}
|
||||
leftIcon={<Settings size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{" "}
|
||||
</Button>
|
||||
{({ hovered = false, pressed = false }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.sm}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -163,9 +164,19 @@ export function WorkspaceDesktopTabsRow({
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabs.length - 1;
|
||||
const isOnlyTab = tabs.length <= 1;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab,
|
||||
index,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsToLeft,
|
||||
onCloseTabsAfter: onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
@@ -285,64 +296,21 @@ export function WorkspaceDesktopTabsRow({
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{tab.kind === "agent" ? (
|
||||
<>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-resume-command`}
|
||||
onSelect={() => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
}}
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
Copy resume command
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-agent-id`}
|
||||
onSelect={() => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
}}
|
||||
>
|
||||
Copy agent id
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-left`}
|
||||
disabled={isFirstTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToLeft(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the left
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-right`}
|
||||
disabled={isLastTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToRight(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-others`}
|
||||
disabled={isOnlyTab}
|
||||
onSelect={() => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close other tabs
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close`}
|
||||
onSelect={() => {
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -13,9 +13,8 @@ import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
PanelRight,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
@@ -143,6 +144,7 @@ function buildOpenIntentKey(input: {
|
||||
}
|
||||
|
||||
type MobileWorkspaceTabSwitcherProps = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
activeTabLabel: string;
|
||||
activeTabPresentation: WorkspaceTabPresentation | null;
|
||||
@@ -151,9 +153,16 @@ type MobileWorkspaceTabSwitcherProps = {
|
||||
tabPresentationsByKey: Map<string, WorkspaceTabPresentation>;
|
||||
onSelectSwitcherTab: (key: string) => void;
|
||||
onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabs,
|
||||
activeTabKey,
|
||||
activeTabLabel,
|
||||
activeTabPresentation,
|
||||
@@ -162,15 +171,29 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabPresentationsByKey,
|
||||
onSelectSwitcherTab,
|
||||
onSelectNewTabOption,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
}: MobileWorkspaceTabSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const tabIndexByKey = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
tabs.forEach((tab, index) => {
|
||||
map.set(tab.key, index);
|
||||
});
|
||||
return map;
|
||||
}, [tabs]);
|
||||
|
||||
return (
|
||||
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
testID="workspace-tab-switcher-trigger"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.switcherTrigger,
|
||||
(hovered || pressed || isOpen) && styles.switcherTriggerActive,
|
||||
@@ -242,12 +265,71 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
const presentation =
|
||||
tabPresentationsByKey.get(option.id) ??
|
||||
deriveWorkspaceTabPresentation({ tab });
|
||||
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries =
|
||||
tabIndex < 0
|
||||
? []
|
||||
: buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab,
|
||||
index: tabIndex,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
return (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
trailingAccessory={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open menu for ${presentation.label}`}
|
||||
hitSlop={8}
|
||||
style={({ open, pressed }) => [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(open || pressed) && styles.mobileTabMenuTriggerActive,
|
||||
]}
|
||||
>
|
||||
<Ellipsis
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -587,6 +669,29 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId, openIntent]
|
||||
);
|
||||
|
||||
const openWorkspaceDraftTab = useCallback(
|
||||
(input?: { draftId?: string; focus?: boolean }) => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
||||
});
|
||||
if (tabId && input?.focus !== false) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
[focusTab, normalizedServerId, normalizedWorkspaceId, openDraftTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOpenIntentKey) {
|
||||
if (resolvedOpenIntentKey !== null) {
|
||||
@@ -619,24 +724,11 @@ function WorkspaceScreenContent({
|
||||
|
||||
if (openIntent.kind === "draft") {
|
||||
const draftId = openIntent.draftId.trim();
|
||||
const tabId =
|
||||
draftId === "new"
|
||||
? openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: generateDraftId(),
|
||||
})
|
||||
: openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
const tabId = openWorkspaceDraftTab({
|
||||
...(draftId === "new" ? {} : { draftId }),
|
||||
focus: true,
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
@@ -653,18 +745,12 @@ function WorkspaceScreenContent({
|
||||
: { kind: "file", path: openIntent.path },
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusTab,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
openOrFocusTab,
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
@@ -821,6 +907,10 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (openIntent) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (workspaceAgents.length > 0 || terminals.length > 0) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
@@ -834,20 +924,12 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
emptyWorkspaceSeedRef.current = workspaceKey;
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
openWorkspaceDraftTab();
|
||||
}, [
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
persistenceKey,
|
||||
terminals.length,
|
||||
tabs.length,
|
||||
@@ -924,19 +1006,8 @@ function WorkspaceScreenContent({
|
||||
}, [activeTabPresentation]);
|
||||
|
||||
const handleCreateDraftTab = useCallback(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
}, [navigateToTabId, normalizedServerId, normalizedWorkspaceId, openDraftTab]);
|
||||
openWorkspaceDraftTab();
|
||||
}, [openWorkspaceDraftTab]);
|
||||
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
if (createTerminalMutation.isPending) {
|
||||
@@ -1375,29 +1446,23 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
if (target.kind === "draft") {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
key={`${normalizedServerId}:${normalizedWorkspaceId}:${tabId}`}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
tabId={activeTabId ?? target.draftId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={handleOpenFileFromChat}
|
||||
onCreated={(agentSnapshot) => {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
|
||||
const nextTabId = retargetWorkspaceTab({
|
||||
retargetWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||
});
|
||||
if (nextTabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: nextTabId,
|
||||
});
|
||||
}
|
||||
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentSnapshot.id, normalized);
|
||||
@@ -1494,12 +1559,15 @@ function WorkspaceScreenContent({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<Ellipsis
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
{({ hovered, open }) => {
|
||||
const Icon = isMobile ? EllipsisVertical : Ellipsis;
|
||||
return (
|
||||
<Icon
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
@@ -1624,8 +1692,8 @@ function WorkspaceScreenContent({
|
||||
{({ hovered }) => {
|
||||
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return isGitCheckout
|
||||
? <GitBranch size={theme.iconSize.lg} color={color} />
|
||||
: <Folder size={theme.iconSize.lg} color={color} />;
|
||||
? <SourceControlPanelIcon size={theme.iconSize.lg} color={color} strokeWidth={1.5} />
|
||||
: <PanelRight size={theme.iconSize.lg} color={color} />;
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
) : null}
|
||||
@@ -1635,6 +1703,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTabLabel={activeTabLabel}
|
||||
activeTabPresentation={activeTabPresentation}
|
||||
@@ -1643,6 +1712,12 @@ function WorkspaceScreenContent({
|
||||
tabPresentationsByKey={tabPresentationsByKey}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceDesktopTabsRow
|
||||
@@ -1868,6 +1943,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
mobileTabMenuTrigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tabsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function createAgentTab(): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
agentId: "agent-123",
|
||||
provider: "codex",
|
||||
label: "Agent 123",
|
||||
subtitle: "/repo",
|
||||
titleState: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabMenuEntries", () => {
|
||||
it("uses desktop tab ordering labels for desktop menus", () => {
|
||||
const onCopyResumeCommand = vi.fn();
|
||||
const onCopyAgentId = vi.fn();
|
||||
const onCloseTab = vi.fn();
|
||||
const onCloseTabsBefore = vi.fn();
|
||||
const onCloseTabsAfter = vi.fn();
|
||||
const onCloseOtherTabs = vi.fn();
|
||||
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-context-agent_123",
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses stacked ordering labels for mobile menus", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-menu-agent_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits agent copy actions for non-agent tabs", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: {
|
||||
key: "draft_123",
|
||||
tabId: "draft_123",
|
||||
kind: "draft",
|
||||
draftId: "draft_123",
|
||||
label: "New agent",
|
||||
subtitle: "/repo",
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase: "workspace-tab-menu-draft_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
|
||||
false
|
||||
);
|
||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
export type WorkspaceTabMenuEntry =
|
||||
| {
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
testID: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| {
|
||||
kind: "separator";
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface BuildWorkspaceTabMenuEntriesInput {
|
||||
surface: WorkspaceTabMenuSurface;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
menuTestIDBase: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||
}
|
||||
|
||||
function buildCloseAfterLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs below" : "Close to the right";
|
||||
}
|
||||
|
||||
function buildCloseBeforeTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-above" : "close-left";
|
||||
}
|
||||
|
||||
function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-below" : "close-right";
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabMenuEntries(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
const {
|
||||
surface,
|
||||
tab,
|
||||
index,
|
||||
tabCount,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
} = input;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabCount - 1;
|
||||
const isOnlyTab = tabCount <= 1;
|
||||
const entries: WorkspaceTabMenuEntry[] = [];
|
||||
|
||||
if (tab.kind === "agent") {
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-agent-id",
|
||||
label: "Copy agent id",
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
});
|
||||
}
|
||||
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-before",
|
||||
label: buildCloseBeforeLabel(surface),
|
||||
disabled: isFirstTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsBefore(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-after",
|
||||
label: buildCloseAfterLabel(surface),
|
||||
disabled: isLastTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsAfter(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-others",
|
||||
label: "Close other tabs",
|
||||
disabled: isOnlyTab,
|
||||
testID: `${menuTestIDBase}-close-others`,
|
||||
onSelect: () => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
label: "Close",
|
||||
testID: `${menuTestIDBase}-close`,
|
||||
onSelect: () => {
|
||||
void onCloseTab(tab.tabId);
|
||||
},
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||
expect(model.tabs[0]?.descriptor.label).toBe("New Agent");
|
||||
});
|
||||
|
||||
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||
|
||||
@@ -169,8 +169,8 @@ export function deriveWorkspaceTabModel(input: {
|
||||
tabId: tab.tabId,
|
||||
kind: "draft",
|
||||
draftId: tab.target.draftId,
|
||||
label: "Draft",
|
||||
subtitle: "Draft",
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -124,6 +124,7 @@ type WorkspaceTabOptionRowProps = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
trailingAccessory?: ReactNode;
|
||||
};
|
||||
|
||||
export function WorkspaceTabOptionRow({
|
||||
@@ -131,30 +132,36 @@ export function WorkspaceTabOptionRow({
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionRow,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.optionRow, active && styles.optionRowActive]}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionMainPressable,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{trailingAccessory ? (
|
||||
<View style={styles.optionTrailingAccessorySlot}>{trailingAccessory}</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,12 +185,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: 0,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
optionMainPressable: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
optionRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
@@ -204,6 +220,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
optionTrailingAccessorySlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -137,6 +137,35 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
const focusedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
});
|
||||
|
||||
useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
|
||||
focusedFileTabId
|
||||
);
|
||||
});
|
||||
|
||||
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
|
||||
@@ -15,6 +15,14 @@ export type SelectBestConnectionInput = {
|
||||
probeByConnectionId: Map<string, ConnectionProbeState>;
|
||||
};
|
||||
|
||||
function getAvailableLatency(input: {
|
||||
connectionId: string;
|
||||
probeByConnectionId: Map<string, ConnectionProbeState>;
|
||||
}): number | null {
|
||||
const probe = input.probeByConnectionId.get(input.connectionId);
|
||||
return probe?.status === "available" ? probe.latencyMs : null;
|
||||
}
|
||||
|
||||
export function selectBestConnection(
|
||||
input: SelectBestConnectionInput
|
||||
): string | null {
|
||||
@@ -23,37 +31,22 @@ export function selectBestConnection(
|
||||
return null;
|
||||
}
|
||||
|
||||
const available = candidates
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.filter(({ candidate }) => {
|
||||
const probe = probeByConnectionId.get(candidate.connectionId);
|
||||
return probe?.status === "available";
|
||||
let bestConnectionId: string | null = null;
|
||||
let bestLatency: number | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const latencyMs = getAvailableLatency({
|
||||
connectionId: candidate.connectionId,
|
||||
probeByConnectionId,
|
||||
});
|
||||
|
||||
const byLatency = (left: { candidate: ConnectionCandidate; index: number }, right: { candidate: ConnectionCandidate; index: number }) => {
|
||||
const leftProbe = probeByConnectionId.get(left.candidate.connectionId);
|
||||
const rightProbe = probeByConnectionId.get(right.candidate.connectionId);
|
||||
|
||||
const leftLatency =
|
||||
leftProbe && leftProbe.status === "available"
|
||||
? leftProbe.latencyMs
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const rightLatency =
|
||||
rightProbe && rightProbe.status === "available"
|
||||
? rightProbe.latencyMs
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
if (leftLatency === rightLatency) {
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
return leftLatency - rightLatency;
|
||||
};
|
||||
|
||||
if (available.length === 0) {
|
||||
return null;
|
||||
if (latencyMs === null) {
|
||||
continue;
|
||||
}
|
||||
if (bestLatency === null || latencyMs < bestLatency) {
|
||||
bestConnectionId = candidate.connectionId;
|
||||
bestLatency = latencyMs;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = available.sort(byLatency);
|
||||
return sorted[0]!.candidate.connectionId;
|
||||
return bestConnectionId;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path";
|
||||
import {
|
||||
normalizeInlinePathTarget,
|
||||
parseAssistantFileLink,
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
} from "./inline-path";
|
||||
|
||||
describe("parseInlinePathToken", () => {
|
||||
it("returns null for plain paths (no line)", () => {
|
||||
it("returns null for plain paths without a line number", () => {
|
||||
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
||||
expect(parseInlinePathToken("README.md")).toBeNull();
|
||||
});
|
||||
@@ -67,6 +72,119 @@ describe("parseFileProtocolUrl", () => {
|
||||
|
||||
it("rejects non-file URLs and invalid ranges", () => {
|
||||
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
||||
expect(
|
||||
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAssistantFileLink", () => {
|
||||
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx#L33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute Windows hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
})
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx#L12-L20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows file URLs even when they are outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
).toEqual({
|
||||
raw: "file:///tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute hrefs outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects external URLs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid line fragments", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
it("keeps relative file paths as file targets", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("packages/app/src/components/message.tsx")
|
||||
).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget(
|
||||
"/Users/test/project/packages/app/src/components/message.tsx",
|
||||
"/Users/test/project"
|
||||
)
|
||||
).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")
|
||||
).toEqual({
|
||||
directory: "/tmp",
|
||||
file: "/tmp/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cwd itself as the workspace root directory", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")
|
||||
).toEqual({
|
||||
directory: ".",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trailing-slash paths as directories", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project")
|
||||
).toEqual({
|
||||
directory: "packages/app",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,16 @@ export interface InlinePathTarget {
|
||||
}
|
||||
|
||||
const FILE_PROTOCOL = "file:";
|
||||
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
||||
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedInlinePathTarget {
|
||||
directory: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
function normalizePathToken(value: string): string | null {
|
||||
const trimmed = value
|
||||
@@ -20,6 +30,27 @@ function normalizePathToken(value: string): string | null {
|
||||
return trimmed.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" | "lineEnd"> | null {
|
||||
const rawFragment = value.startsWith("#") ? value.slice(1) : value;
|
||||
if (!rawFragment) {
|
||||
return { lineStart: undefined, lineEnd: undefined };
|
||||
}
|
||||
|
||||
const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT);
|
||||
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
|
||||
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
|
||||
|
||||
if (
|
||||
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
|
||||
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
|
||||
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict VSCode-style markers only.
|
||||
*
|
||||
@@ -103,29 +134,151 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawFragment = parsedUrl.hash.startsWith("#")
|
||||
? parsedUrl.hash.slice(1)
|
||||
: parsedUrl.hash;
|
||||
const lineMatch = rawFragment.match(/^L([0-9]+)(?:-L?([0-9]+))?$/i);
|
||||
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
|
||||
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
|
||||
|
||||
if (
|
||||
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
|
||||
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
|
||||
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
|
||||
) {
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAssistantFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions = {}
|
||||
): InlinePathTarget | null {
|
||||
const fileUrlTarget = parseFileProtocolUrl(value);
|
||||
if (fileUrlTarget) {
|
||||
return fileUrlTarget;
|
||||
}
|
||||
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.includes("://")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||
if (!normalizedPath || !normalizedPath.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeInlinePathTarget(
|
||||
rawPath: string,
|
||||
cwd?: string
|
||||
): NormalizedInlinePathTarget | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = normalizePathInput(rawPath);
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = normalizedInput;
|
||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||
if (cwdRelative) {
|
||||
normalized = cwdRelative;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2) || ".";
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
normalized = ".";
|
||||
}
|
||||
|
||||
if (normalized === ".") {
|
||||
return { directory: "." };
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
const dir = normalized.replace(/\/+$/, "");
|
||||
return { directory: dir.length > 0 ? dir : "." };
|
||||
}
|
||||
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
|
||||
return {
|
||||
directory: directory.length > 0 ? directory : ".",
|
||||
file: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareWorkspaceRoot = normalizePathForCompare(
|
||||
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/"
|
||||
);
|
||||
const comparePrefix =
|
||||
compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
||||
|
||||
return (
|
||||
comparePath === compareWorkspaceRoot ||
|
||||
comparePath.startsWith(comparePrefix)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFileUrlPath(pathname: string): string | null {
|
||||
if (!pathname) {
|
||||
return null;
|
||||
@@ -142,3 +295,54 @@ function normalizeFileUrlPath(pathname: string): string | null {
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (
|
||||
!normalizedCwd ||
|
||||
!isAbsolutePath(pathValue) ||
|
||||
!isAbsolutePath(normalizedCwd)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||
const comparePrefix = normalizePathForCompare(prefix);
|
||||
|
||||
if (comparePath === compareCwd) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (comparePath.startsWith(comparePrefix)) {
|
||||
return pathValue.slice(prefix.length) || ".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ export interface SidebarShortcutModel {
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
}
|
||||
|
||||
function createShortcutTarget(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
): SidebarShortcutWorkspaceTarget {
|
||||
return {
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSidebarShortcutModel(input: {
|
||||
projects: SidebarProjectEntry[]
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
@@ -30,19 +39,15 @@ export function buildSidebarShortcutModel(input: {
|
||||
}
|
||||
|
||||
for (const workspace of project.workspaces) {
|
||||
visibleTargets.push({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
})
|
||||
const shortcutNumber =
|
||||
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
|
||||
if (shortcutNumber !== null) {
|
||||
shortcutTargets.push({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
})
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
visibleTargets.push(createShortcutTarget(workspace))
|
||||
|
||||
if (shortcutTargets.length >= maxShortcuts) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shortcutNumber = shortcutTargets.length + 1
|
||||
shortcutTargets.push(createShortcutTarget(workspace))
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,4 @@ export interface AudioEngine {
|
||||
stop(): void;
|
||||
clearQueue(): void;
|
||||
isPlaying(): boolean;
|
||||
|
||||
playLooping(pcm: Uint8Array, gapMs: number): void;
|
||||
stopLooping(): void;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import {
|
||||
addExpoTwoWayAudioEventListener,
|
||||
getMicrophonePermissionsAsync,
|
||||
initialize,
|
||||
playPCMData,
|
||||
requestMicrophonePermissionsAsync,
|
||||
resumePlayback,
|
||||
stopPlayback,
|
||||
tearDown,
|
||||
toggleRecording,
|
||||
} from "@boudra/expo-two-way-audio";
|
||||
import {
|
||||
THINKING_TONE_REPEAT_GAP_MS,
|
||||
} from "@/utils/thinking-tone";
|
||||
import {
|
||||
THINKING_TONE_NATIVE_PCM_BASE64,
|
||||
THINKING_TONE_NATIVE_PCM_DURATION_MS,
|
||||
} from "@/utils/thinking-tone.native-pcm";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioPlaybackSource,
|
||||
} from "@/voice/audio-engine-types";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
interface QueuedAudio {
|
||||
audio: AudioPlaybackSource;
|
||||
@@ -29,60 +10,22 @@ interface QueuedAudio {
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface CuePcm {
|
||||
pcm16k: Uint8Array;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface AudioEngineTraceOptions {
|
||||
traceLabel?: string;
|
||||
}
|
||||
|
||||
function resamplePcm16(
|
||||
pcm: Uint8Array,
|
||||
fromRate: number,
|
||||
toRate: number
|
||||
): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
let nextAudioEngineInstanceId = 1;
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const output = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const offset = sampleIndex * 2;
|
||||
if (offset + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[offset]!;
|
||||
const hi = pcm[offset + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value -= 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const offset = sampleIndex * 2;
|
||||
output[offset] = clamped & 0xff;
|
||||
output[offset + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i += 1) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const frac = sourceIndex - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return output;
|
||||
interface BridgeStats {
|
||||
windowStartedAtMs: number;
|
||||
captureEvents: number;
|
||||
captureBytes: number;
|
||||
volumeEvents: number;
|
||||
volumeMax: number;
|
||||
playbackEvents: number;
|
||||
playbackInputBytes: number;
|
||||
playbackResampledBytes: number;
|
||||
playbackDurationMs: number;
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
@@ -94,10 +37,97 @@ function parsePcmSampleRate(mimeType: string): number | null {
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const out = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const i = sampleIndex * 2;
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i]!;
|
||||
const hi = pcm[i + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const i = sampleIndex * 2;
|
||||
out[i] = clamped & 0xff;
|
||||
out[i + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcPos = i * ratio;
|
||||
const i0 = Math.floor(srcPos);
|
||||
const frac = srcPos - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: AudioEngineTraceOptions
|
||||
): AudioEngine {
|
||||
const native = require("@getpaseo/expo-two-way-audio");
|
||||
const instanceId = nextAudioEngineInstanceId++;
|
||||
const bridgeStats: BridgeStats = {
|
||||
windowStartedAtMs: Date.now(),
|
||||
captureEvents: 0,
|
||||
captureBytes: 0,
|
||||
volumeEvents: 0,
|
||||
volumeMax: 0,
|
||||
playbackEvents: 0,
|
||||
playbackInputBytes: 0,
|
||||
playbackResampledBytes: 0,
|
||||
playbackDurationMs: 0,
|
||||
};
|
||||
|
||||
const toHexPreview = (bytes: Uint8Array, count = 12): string =>
|
||||
Array.from(bytes.slice(0, count))
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
|
||||
const maybeFlushBridgeStats = (reason: string): void => {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bridgeStats.windowStartedAtMs;
|
||||
if (elapsedMs < 1000) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
|
||||
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
||||
`volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` +
|
||||
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
|
||||
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
|
||||
`windowMs=${elapsedMs}`
|
||||
);
|
||||
bridgeStats.windowStartedAtMs = now;
|
||||
bridgeStats.captureEvents = 0;
|
||||
bridgeStats.captureBytes = 0;
|
||||
bridgeStats.volumeEvents = 0;
|
||||
bridgeStats.volumeMax = 0;
|
||||
bridgeStats.playbackEvents = 0;
|
||||
bridgeStats.playbackInputBytes = 0;
|
||||
bridgeStats.playbackResampledBytes = 0;
|
||||
bridgeStats.playbackDurationMs = 0;
|
||||
};
|
||||
|
||||
const refs: {
|
||||
initialized: boolean;
|
||||
captureActive: boolean;
|
||||
@@ -110,12 +140,8 @@ export function createAudioEngine(
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
looping: {
|
||||
active: boolean;
|
||||
token: number;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
thinkingTone: CuePcm | null;
|
||||
sawFirstMicChunk: boolean;
|
||||
sawFirstVolumeEvent: boolean;
|
||||
destroyed: boolean;
|
||||
} = {
|
||||
initialized: false,
|
||||
@@ -125,48 +151,79 @@ export function createAudioEngine(
|
||||
processingQueue: false,
|
||||
playbackTimeout: null,
|
||||
activePlayback: null,
|
||||
looping: {
|
||||
active: false,
|
||||
token: 0,
|
||||
timeout: null,
|
||||
},
|
||||
thinkingTone: null,
|
||||
sawFirstMicChunk: false,
|
||||
sawFirstVolumeEvent: false,
|
||||
destroyed: false,
|
||||
};
|
||||
|
||||
const microphoneSubscription = addExpoTwoWayAudioEventListener(
|
||||
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event) => {
|
||||
(event: any) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
callbacks.onCaptureData(event.data);
|
||||
const pcm = event.data as Uint8Array;
|
||||
if (!refs.sawFirstMicChunk) {
|
||||
refs.sawFirstMicChunk = true;
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`
|
||||
);
|
||||
}
|
||||
bridgeStats.captureEvents += 1;
|
||||
bridgeStats.captureBytes += pcm.byteLength;
|
||||
maybeFlushBridgeStats("capture");
|
||||
callbacks.onCaptureData(pcm);
|
||||
}
|
||||
);
|
||||
|
||||
const volumeSubscription = addExpoTwoWayAudioEventListener(
|
||||
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event) => {
|
||||
(event: any) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
bridgeStats.volumeEvents += 1;
|
||||
bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level);
|
||||
if (!refs.sawFirstVolumeEvent) {
|
||||
refs.sawFirstVolumeEvent = true;
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`
|
||||
);
|
||||
}
|
||||
maybeFlushBridgeStats("volume");
|
||||
callbacks.onVolumeLevel(level);
|
||||
}
|
||||
);
|
||||
|
||||
const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onOutputVolumeLevelData",
|
||||
(event: any) => {
|
||||
console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`);
|
||||
}
|
||||
);
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
return;
|
||||
}
|
||||
await initialize();
|
||||
const success = await native.initialize();
|
||||
if (!success) {
|
||||
throw new Error("expo-two-way-audio: native initialize() returned false");
|
||||
}
|
||||
console.log(`[AudioEngine.native#${instanceId}] initialized successfully`);
|
||||
refs.initialized = true;
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission(): Promise<void> {
|
||||
let permission = await getMicrophonePermissionsAsync().catch(() => null);
|
||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`
|
||||
);
|
||||
if (!permission?.granted) {
|
||||
permission = await requestMicrophonePermissionsAsync().catch(() => null);
|
||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`
|
||||
);
|
||||
}
|
||||
if (!permission?.granted) {
|
||||
throw new Error(
|
||||
@@ -175,16 +232,6 @@ export function createAudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureThinkingTone(): Promise<CuePcm> {
|
||||
if (refs.thinkingTone) {
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
const pcm16k = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64");
|
||||
const durationMs = THINKING_TONE_NATIVE_PCM_DURATION_MS;
|
||||
refs.thinkingTone = { pcm16k, durationMs };
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
|
||||
function clearPlaybackTimeout(): void {
|
||||
if (refs.playbackTimeout) {
|
||||
clearTimeout(refs.playbackTimeout);
|
||||
@@ -194,7 +241,6 @@ export function createAudioEngine(
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
await ensureInitialized();
|
||||
resumePlayback();
|
||||
|
||||
return await new Promise<number>(async (resolve, reject) => {
|
||||
refs.activePlayback = { resolve, reject, settled: false };
|
||||
@@ -203,10 +249,25 @@ export function createAudioEngine(
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const pcm = new Uint8Array(arrayBuffer);
|
||||
const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000;
|
||||
|
||||
// Native AudioEngine expects 16kHz PCM16
|
||||
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
|
||||
const durationSec = pcm16k.length / 2 / 16000;
|
||||
bridgeStats.playbackEvents += 1;
|
||||
bridgeStats.playbackInputBytes += pcm.length;
|
||||
bridgeStats.playbackResampledBytes += pcm16k.length;
|
||||
bridgeStats.playbackDurationMs += durationSec * 1000;
|
||||
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` +
|
||||
`resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` +
|
||||
`pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`
|
||||
);
|
||||
maybeFlushBridgeStats("play");
|
||||
|
||||
native.resumePlayback();
|
||||
native.playPCMData(pcm16k);
|
||||
|
||||
playPCMData(pcm16k);
|
||||
clearPlaybackTimeout();
|
||||
refs.playbackTimeout = setTimeout(() => {
|
||||
clearPlaybackTimeout();
|
||||
@@ -248,20 +309,9 @@ export function createAudioEngine(
|
||||
refs.processingQueue = false;
|
||||
}
|
||||
|
||||
function stopLooping(): void {
|
||||
refs.looping.active = false;
|
||||
refs.looping.token += 1;
|
||||
if (refs.looping.timeout) {
|
||||
clearTimeout(refs.looping.timeout);
|
||||
refs.looping.timeout = null;
|
||||
}
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
return {
|
||||
async initialize() {
|
||||
await ensureInitialized();
|
||||
await ensureThinkingTone();
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
@@ -269,34 +319,41 @@ export function createAudioEngine(
|
||||
return;
|
||||
}
|
||||
refs.destroyed = true;
|
||||
stopLooping();
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
if (refs.captureActive) {
|
||||
toggleRecording(false);
|
||||
native.toggleRecording(false);
|
||||
refs.captureActive = false;
|
||||
}
|
||||
clearPlaybackTimeout();
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (refs.initialized) {
|
||||
tearDown();
|
||||
native.tearDown();
|
||||
refs.initialized = false;
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
volumeSubscription.remove();
|
||||
outputVolumeSubscription.remove();
|
||||
},
|
||||
|
||||
async startCapture() {
|
||||
if (refs.captureActive) {
|
||||
console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[AudioEngine.native#${instanceId}] startCapture begin`);
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
toggleRecording(true);
|
||||
refs.sawFirstMicChunk = false;
|
||||
refs.sawFirstVolumeEvent = false;
|
||||
const isRecording = native.toggleRecording(true);
|
||||
refs.captureActive = true;
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`
|
||||
);
|
||||
} catch (error) {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
callbacks.onError?.(wrapped);
|
||||
@@ -306,7 +363,10 @@ export function createAudioEngine(
|
||||
|
||||
async stopCapture() {
|
||||
if (refs.captureActive) {
|
||||
toggleRecording(false);
|
||||
const isRecording = native.toggleRecording(false);
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`
|
||||
);
|
||||
}
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
@@ -335,7 +395,7 @@ export function createAudioEngine(
|
||||
},
|
||||
|
||||
stop() {
|
||||
stopPlayback();
|
||||
native.stopPlayback();
|
||||
clearPlaybackTimeout();
|
||||
const active = refs.activePlayback;
|
||||
refs.activePlayback = null;
|
||||
@@ -355,48 +415,5 @@ export function createAudioEngine(
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
|
||||
playLooping(audio, gapMs) {
|
||||
if (refs.looping.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
refs.looping.active = true;
|
||||
const token = refs.looping.token + 1;
|
||||
refs.looping.token = token;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const cue =
|
||||
audio.byteLength > 0
|
||||
? {
|
||||
pcm16k: audio,
|
||||
durationMs: (audio.byteLength / 2 / 16000) * 1000,
|
||||
}
|
||||
: await ensureThinkingTone();
|
||||
|
||||
const loop = () => {
|
||||
if (!refs.looping.active || refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
resumePlayback();
|
||||
playPCMData(cue.pcm16k);
|
||||
refs.looping.timeout = setTimeout(
|
||||
loop,
|
||||
cue.durationMs + (gapMs || THINKING_TONE_REPEAT_GAP_MS)
|
||||
);
|
||||
};
|
||||
|
||||
loop();
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
stopLooping,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import {
|
||||
loadThinkingToneArrayBuffer,
|
||||
THINKING_TONE_REPEAT_GAP_MS,
|
||||
} from "@/utils/thinking-tone";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
@@ -117,16 +113,6 @@ export function createAudioEngine(
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
looping: {
|
||||
token: number;
|
||||
source: AudioBufferSourceNode | null;
|
||||
gain: GainNode | null;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
thinkingTone: {
|
||||
bytes: Uint8Array | null;
|
||||
buffer: AudioBuffer | null;
|
||||
};
|
||||
} = {
|
||||
playbackContext: null,
|
||||
captureContext: null,
|
||||
@@ -139,16 +125,6 @@ export function createAudioEngine(
|
||||
queue: [],
|
||||
processingQueue: false,
|
||||
activePlayback: null,
|
||||
looping: {
|
||||
token: 0,
|
||||
source: null,
|
||||
gain: null,
|
||||
timeout: null,
|
||||
},
|
||||
thinkingTone: {
|
||||
bytes: null,
|
||||
buffer: null,
|
||||
},
|
||||
};
|
||||
|
||||
async function ensurePlaybackContext(): Promise<AudioContext> {
|
||||
@@ -193,48 +169,6 @@ export function createAudioEngine(
|
||||
return context;
|
||||
}
|
||||
|
||||
async function ensureThinkingToneBytes(): Promise<Uint8Array> {
|
||||
if (refs.thinkingTone.bytes) {
|
||||
return refs.thinkingTone.bytes;
|
||||
}
|
||||
const wav = await loadThinkingToneArrayBuffer();
|
||||
refs.thinkingTone.bytes = new Uint8Array(wav);
|
||||
return refs.thinkingTone.bytes;
|
||||
}
|
||||
|
||||
async function ensureThinkingToneBuffer(): Promise<AudioBuffer> {
|
||||
if (refs.thinkingTone.buffer) {
|
||||
return refs.thinkingTone.buffer;
|
||||
}
|
||||
const context = await ensurePlaybackContext();
|
||||
const wav = await loadThinkingToneArrayBuffer();
|
||||
const audioBuffer = await decodeAudioData(context, wav);
|
||||
refs.thinkingTone.buffer = audioBuffer;
|
||||
return audioBuffer;
|
||||
}
|
||||
|
||||
function stopLooping(): void {
|
||||
refs.looping.token += 1;
|
||||
if (refs.looping.timeout) {
|
||||
clearTimeout(refs.looping.timeout);
|
||||
refs.looping.timeout = null;
|
||||
}
|
||||
if (refs.looping.source) {
|
||||
try {
|
||||
refs.looping.source.onended = null;
|
||||
refs.looping.source.stop();
|
||||
} catch {
|
||||
// Ignore best-effort stop errors.
|
||||
}
|
||||
refs.looping.source.disconnect();
|
||||
refs.looping.source = null;
|
||||
}
|
||||
if (refs.looping.gain) {
|
||||
refs.looping.gain.disconnect();
|
||||
refs.looping.gain = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
const context = await ensurePlaybackContext();
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
@@ -335,18 +269,15 @@ export function createAudioEngine(
|
||||
return {
|
||||
async initialize() {
|
||||
await ensurePlaybackContext();
|
||||
await Promise.all([ensureThinkingToneBytes(), ensureThinkingToneBuffer()]);
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
stopLooping();
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
await stopCapture();
|
||||
|
||||
const playbackContext = refs.playbackContext;
|
||||
refs.playbackContext = null;
|
||||
refs.thinkingTone.buffer = null;
|
||||
if (playbackContext && playbackContext.state !== "closed") {
|
||||
await playbackContext.close().catch(() => undefined);
|
||||
}
|
||||
@@ -486,63 +417,5 @@ export function createAudioEngine(
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
|
||||
playLooping(audio, gapMs) {
|
||||
if (refs.looping.source || refs.looping.timeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = refs.looping.token + 1;
|
||||
refs.looping.token = token;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const context = await ensurePlaybackContext();
|
||||
const audioBuffer =
|
||||
audio.byteLength > 0
|
||||
? pcm16LeToAudioBuffer(context, audio, 16000)
|
||||
: await ensureThinkingToneBuffer();
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 1;
|
||||
gain.connect(context.destination);
|
||||
refs.looping.gain = gain;
|
||||
|
||||
const playOnce = () => {
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(gain);
|
||||
refs.looping.source = source;
|
||||
source.onended = () => {
|
||||
if (refs.looping.source === source) {
|
||||
refs.looping.source = null;
|
||||
}
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
refs.looping.timeout = setTimeout(
|
||||
playOnce,
|
||||
gapMs || THINKING_TONE_REPEAT_GAP_MS
|
||||
);
|
||||
};
|
||||
source.start();
|
||||
};
|
||||
|
||||
playOnce();
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
stopLooping,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SpeechSegmenter } from "./speech-segmenter";
|
||||
|
||||
const mkPcmBytes = (length: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) bytes[i] = i % 255;
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("SpeechSegmenter", () => {
|
||||
it("streams continuously and flushes at stop", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: true,
|
||||
volumeThreshold: 0.3,
|
||||
silenceDurationMs: 2000,
|
||||
speechConfirmationMs: 300,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 100, // ensure we only flush on stop
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{ onAudioSegment }
|
||||
);
|
||||
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onAudioSegment).not.toHaveBeenCalled();
|
||||
|
||||
segmenter.stop(Date.now());
|
||||
|
||||
expect(onAudioSegment).toHaveBeenCalledTimes(1);
|
||||
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
||||
expect(lastCall.isLast).toBe(true);
|
||||
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects speech, calls onSpeechStart, and flushes on speech end", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const detectingChanges: boolean[] = [];
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.3,
|
||||
silenceDurationMs: 2000,
|
||||
speechConfirmationMs: 300,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 100,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onAudioSegment,
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onDetectingChange: (v) => detectingChanges.push(v),
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 10_000;
|
||||
|
||||
// Start detection.
|
||||
segmenter.pushVolumeLevel(0.5, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(detectingChanges).toEqual([true]);
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
expect(onSpeechStart).not.toHaveBeenCalled();
|
||||
|
||||
// Confirm speech.
|
||||
segmenter.pushVolumeLevel(0.5, t0 + 300);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
expect(detectingChanges).toEqual([true, false]);
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
|
||||
// End speech after silence.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000 + 2000);
|
||||
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
expect(detectingChanges).toEqual([true, false, true, false]);
|
||||
expect(speakingChanges).toEqual([true, false]);
|
||||
expect(onAudioSegment).toHaveBeenCalled();
|
||||
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
||||
expect(lastCall.isLast).toBe(true);
|
||||
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps detection alive across a brief pause and confirms short utterances", () => {
|
||||
const onSpeechStart = vi.fn();
|
||||
const detectingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.18,
|
||||
silenceDurationMs: 1400,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 700,
|
||||
minChunkDurationMs: 100,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeechStart,
|
||||
onDetectingChange: (v) => detectingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 20_000;
|
||||
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 80);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 140);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||
|
||||
expect(detectingChanges).toContain(true);
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps utterance open after fade-out and resumes speaking during silence grace", () => {
|
||||
const onAudioSegment = vi.fn();
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.2,
|
||||
silenceDurationMs: 1500,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 400,
|
||||
minChunkDurationMs: 1000, // keep everything buffered until final flush
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onAudioSegment,
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 30_000;
|
||||
|
||||
// Start + confirm speaking.
|
||||
segmenter.pushVolumeLevel(0.6, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.6, t0 + 130);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Drop below threshold: meter should fade, but turn remains open.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 200);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 500);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
|
||||
// Speak again before silence timeout expires: continue same utterance.
|
||||
segmenter.pushVolumeLevel(0.6, t0 + 900);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
expect(onSpeechEnd).not.toHaveBeenCalled();
|
||||
|
||||
// End for real.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 1000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(200));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 2600);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 4200);
|
||||
|
||||
expect(speakingChanges).toEqual([true, false, true, false]);
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
const finalCalls = onAudioSegment.mock.calls.filter((call) => call[0].isLast);
|
||||
expect(finalCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps confirmed speech active above release threshold even when below start threshold", () => {
|
||||
const onSpeechStart = vi.fn();
|
||||
const onSpeechEnd = vi.fn();
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.3,
|
||||
volumeReleaseThreshold: 0.1,
|
||||
silenceDurationMs: 1200,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 1000,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 40_000;
|
||||
|
||||
// Confirm speech.
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 130);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Stay between start and release thresholds for longer than silence timeout:
|
||||
// should still be considered speaking.
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 400);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 1000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.15, t0 + 2000);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
|
||||
expect(onSpeechEnd).not.toHaveBeenCalled();
|
||||
expect(speakingChanges.at(-1)).toBe(true);
|
||||
|
||||
// Now actually drop below release threshold and allow silence timeout.
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 2001);
|
||||
segmenter.pushPcmChunk(mkPcmBytes(50));
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 3405);
|
||||
segmenter.pushVolumeLevel(0.0, t0 + 4706);
|
||||
|
||||
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
||||
expect(speakingChanges.at(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores brief confirmed dips below release threshold without flip-flopping speaking", () => {
|
||||
const speakingChanges: boolean[] = [];
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: 0.2,
|
||||
volumeReleaseThreshold: 0.08,
|
||||
confirmedDropGracePeriodMs: 250,
|
||||
silenceDurationMs: 1500,
|
||||
speechConfirmationMs: 120,
|
||||
detectionGracePeriodMs: 200,
|
||||
minChunkDurationMs: 1000,
|
||||
pcmSampleRate: 1000,
|
||||
},
|
||||
{
|
||||
onSpeakingChange: (v) => speakingChanges.push(v),
|
||||
}
|
||||
);
|
||||
|
||||
const t0 = 50_000;
|
||||
segmenter.pushVolumeLevel(0.4, t0);
|
||||
segmenter.pushVolumeLevel(0.4, t0 + 130); // confirm speaking
|
||||
|
||||
// Two short dips below release threshold; both shorter than debounce.
|
||||
segmenter.pushVolumeLevel(0.05, t0 + 300);
|
||||
segmenter.pushVolumeLevel(0.09, t0 + 500);
|
||||
segmenter.pushVolumeLevel(0.05, t0 + 620);
|
||||
segmenter.pushVolumeLevel(0.1, t0 + 760);
|
||||
|
||||
expect(speakingChanges).toEqual([true]);
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export type SpeechSegment = { audioData: string; isLast: boolean };
|
||||
|
||||
export type SpeechSegmenterConfig = {
|
||||
enableContinuousStreaming: boolean;
|
||||
volumeThreshold: number; // 0..1
|
||||
volumeReleaseThreshold?: number; // 0..1; lower threshold used to keep active speech from dropping out
|
||||
confirmedDropGracePeriodMs?: number; // ms dip debounce before leaving confirmed speaking
|
||||
silenceDurationMs: number;
|
||||
speechConfirmationMs: number;
|
||||
detectionGracePeriodMs: number;
|
||||
minChunkDurationMs?: number;
|
||||
pcmSampleRate?: number;
|
||||
pcmChannels?: number;
|
||||
pcmBitsPerSample?: number;
|
||||
};
|
||||
|
||||
export type SpeechSegmenterCallbacks = {
|
||||
onAudioSegment?: (segment: SpeechSegment) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onDetectingChange?: (isDetecting: boolean) => void;
|
||||
onSpeakingChange?: (isSpeaking: boolean) => void;
|
||||
};
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
// NOTE: This is performance-sensitive during continuous streaming.
|
||||
// Buffer-backed base64 is significantly faster than manual string building.
|
||||
try {
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
||||
} catch {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SpeechSegmenter {
|
||||
private config: Required<Omit<SpeechSegmenterConfig, "minChunkDurationMs" | "pcmSampleRate" | "pcmChannels" | "pcmBitsPerSample">> &
|
||||
Required<Pick<SpeechSegmenterConfig, "minChunkDurationMs" | "pcmSampleRate" | "pcmChannels" | "pcmBitsPerSample">>;
|
||||
private callbacks: SpeechSegmenterCallbacks;
|
||||
|
||||
private audioBuffer: Uint8Array[] = [];
|
||||
private bufferedBytes = 0;
|
||||
|
||||
private speechDetectionStartMs: number | null = null;
|
||||
private detectionSilenceStartMs: number | null = null;
|
||||
private confirmedDropStartMs: number | null = null;
|
||||
private silenceStartMs: number | null = null;
|
||||
private isSpeaking = false;
|
||||
private isDetecting = false;
|
||||
private speechConfirmed = false;
|
||||
|
||||
private pcmBytesPerMs: number;
|
||||
private minChunkBytes: number;
|
||||
|
||||
constructor(config: SpeechSegmenterConfig, callbacks: SpeechSegmenterCallbacks = {}) {
|
||||
const volumeReleaseThreshold = this.resolveVolumeReleaseThreshold(
|
||||
config.volumeThreshold,
|
||||
config.volumeReleaseThreshold
|
||||
);
|
||||
this.config = {
|
||||
...config,
|
||||
volumeReleaseThreshold,
|
||||
confirmedDropGracePeriodMs: config.confirmedDropGracePeriodMs ?? 250,
|
||||
minChunkDurationMs: config.minChunkDurationMs ?? 1000,
|
||||
pcmSampleRate: config.pcmSampleRate ?? 16000,
|
||||
pcmChannels: config.pcmChannels ?? 1,
|
||||
pcmBitsPerSample: config.pcmBitsPerSample ?? 16,
|
||||
};
|
||||
this.callbacks = callbacks;
|
||||
this.pcmBytesPerMs = 0;
|
||||
this.minChunkBytes = 0;
|
||||
this.recomputeDerived();
|
||||
}
|
||||
|
||||
updateConfig(next: SpeechSegmenterConfig): void {
|
||||
const volumeReleaseThreshold = this.resolveVolumeReleaseThreshold(
|
||||
next.volumeThreshold,
|
||||
next.volumeReleaseThreshold
|
||||
);
|
||||
this.config = {
|
||||
...next,
|
||||
volumeReleaseThreshold,
|
||||
confirmedDropGracePeriodMs: next.confirmedDropGracePeriodMs ?? this.config.confirmedDropGracePeriodMs,
|
||||
minChunkDurationMs: next.minChunkDurationMs ?? this.config.minChunkDurationMs,
|
||||
pcmSampleRate: next.pcmSampleRate ?? this.config.pcmSampleRate,
|
||||
pcmChannels: next.pcmChannels ?? this.config.pcmChannels,
|
||||
pcmBitsPerSample: next.pcmBitsPerSample ?? this.config.pcmBitsPerSample,
|
||||
};
|
||||
this.recomputeDerived();
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: SpeechSegmenterCallbacks): void {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
getSpeechDetectionStartMs(): number | null {
|
||||
return this.speechDetectionStartMs;
|
||||
}
|
||||
|
||||
getIsSpeaking(): boolean {
|
||||
return this.isSpeaking;
|
||||
}
|
||||
|
||||
getIsDetecting(): boolean {
|
||||
return this.isDetecting;
|
||||
}
|
||||
|
||||
getVolumeReleaseThreshold(): number {
|
||||
return this.config.volumeReleaseThreshold;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.isSpeaking = false;
|
||||
this.isDetecting = false;
|
||||
this.speechConfirmed = false;
|
||||
}
|
||||
|
||||
flush(isLast: boolean): void {
|
||||
if (this.audioBuffer.length === 0) {
|
||||
this.bufferedBytes = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBinary = concatenateUint8Arrays(this.audioBuffer);
|
||||
const pcmBase64 = uint8ArrayToBase64(combinedBinary);
|
||||
this.callbacks.onAudioSegment?.({ audioData: pcmBase64, isLast });
|
||||
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the input stream emits a PCM16 chunk (Uint8Array of bytes).
|
||||
* In non-continuous mode, chunk buffering is gated by the VAD state.
|
||||
*/
|
||||
pushPcmChunk(chunk: Uint8Array): void {
|
||||
if (chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
this.audioBuffer.push(chunk);
|
||||
this.bufferedBytes += chunk.length;
|
||||
if (this.bufferedBytes >= this.minChunkBytes) {
|
||||
this.flush(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer if we're detecting or speaking. When speech is confirmed, we also
|
||||
// stream partial chunks to reduce latency.
|
||||
if (this.speechDetectionStartMs !== null || this.isSpeaking) {
|
||||
this.audioBuffer.push(chunk);
|
||||
this.bufferedBytes += chunk.length;
|
||||
|
||||
if (this.speechConfirmed && this.bufferedBytes >= this.minChunkBytes) {
|
||||
this.flush(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called with a normalized volume level (0..1). Drives VAD transitions.
|
||||
*/
|
||||
pushVolumeLevel(volume: number, nowMs: number): void {
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speechDetectedForStart = volume > this.config.volumeThreshold;
|
||||
const speechDetectedForContinue = volume > this.config.volumeReleaseThreshold;
|
||||
|
||||
if (!this.speechConfirmed) {
|
||||
if (speechDetectedForStart) {
|
||||
// Initial detection phase.
|
||||
this.confirmedDropStartMs = null;
|
||||
if (this.speechDetectionStartMs === null) {
|
||||
this.speechDetectionStartMs = nowMs;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.setDetecting(true);
|
||||
this.setSpeaking(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Volume is back above threshold - reset grace period.
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.setSpeaking(true);
|
||||
|
||||
const speechDuration = nowMs - this.speechDetectionStartMs;
|
||||
if (speechDuration >= this.config.speechConfirmationMs) {
|
||||
this.speechConfirmed = true;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.callbacks.onSpeechStart?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.speechDetectionStartMs !== null) {
|
||||
// Volume dropped during detection phase - apply grace period.
|
||||
if (this.detectionSilenceStartMs === null) {
|
||||
this.detectionSilenceStartMs = nowMs;
|
||||
this.setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const graceDuration = nowMs - this.detectionSilenceStartMs;
|
||||
if (graceDuration >= this.config.detectionGracePeriodMs) {
|
||||
// Cancel detection.
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.audioBuffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (speechDetectedForContinue) {
|
||||
// Continuing confirmed speech (or resuming during silence grace).
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.confirmedDropStartMs === null) {
|
||||
this.confirmedDropStartMs = nowMs;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedDropDuration = nowMs - this.confirmedDropStartMs;
|
||||
if (confirmedDropDuration < this.config.confirmedDropGracePeriodMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Potential speech END while utterance remains open.
|
||||
if (this.silenceStartMs === null) {
|
||||
this.silenceStartMs = nowMs;
|
||||
this.setDetecting(true);
|
||||
this.setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const silenceDuration = nowMs - this.silenceStartMs;
|
||||
if (silenceDuration >= this.config.silenceDurationMs) {
|
||||
// Speech END confirmed.
|
||||
this.speechConfirmed = false;
|
||||
this.speechDetectionStartMs = null;
|
||||
this.detectionSilenceStartMs = null;
|
||||
this.confirmedDropStartMs = null;
|
||||
this.silenceStartMs = null;
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
this.callbacks.onSpeechEnd?.();
|
||||
if (this.audioBuffer.length === 0) {
|
||||
// Important: downstream expects an explicit segment boundary marker.
|
||||
this.callbacks.onAudioSegment?.({ audioData: "", isLast: true });
|
||||
} else {
|
||||
this.flush(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(nowMs: number): void {
|
||||
void nowMs;
|
||||
if (this.config.enableContinuousStreaming) {
|
||||
this.flush(true);
|
||||
}
|
||||
// Ensure observers see state clear transitions.
|
||||
this.setDetecting(false);
|
||||
this.setSpeaking(false);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private setDetecting(next: boolean): void {
|
||||
if (this.isDetecting === next) {
|
||||
return;
|
||||
}
|
||||
this.isDetecting = next;
|
||||
this.callbacks.onDetectingChange?.(next);
|
||||
}
|
||||
|
||||
private setSpeaking(next: boolean): void {
|
||||
if (this.isSpeaking === next) {
|
||||
return;
|
||||
}
|
||||
this.isSpeaking = next;
|
||||
this.callbacks.onSpeakingChange?.(next);
|
||||
}
|
||||
|
||||
private recomputeDerived(): void {
|
||||
this.pcmBytesPerMs =
|
||||
(this.config.pcmSampleRate *
|
||||
this.config.pcmChannels *
|
||||
(this.config.pcmBitsPerSample / 8)) /
|
||||
1000;
|
||||
this.minChunkBytes = Math.round(this.pcmBytesPerMs * this.config.minChunkDurationMs);
|
||||
}
|
||||
|
||||
private resolveVolumeReleaseThreshold(
|
||||
volumeThreshold: number,
|
||||
configured?: number
|
||||
): number {
|
||||
const defaultReleaseThreshold = volumeThreshold * 0.4;
|
||||
const raw = configured ?? defaultReleaseThreshold;
|
||||
return Math.max(0, Math.min(raw, volumeThreshold));
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@ function createAudioEngineMock(): AudioEngine {
|
||||
stop: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
isPlaying: vi.fn().mockReturnValue(false),
|
||||
playLooping: vi.fn(),
|
||||
stopLooping: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,11 +28,30 @@ function createSessionAdapter(serverId = "server-1"): VoiceSessionAdapter {
|
||||
serverId,
|
||||
setVoiceMode: vi.fn().mockResolvedValue(undefined),
|
||||
sendVoiceAudioChunk: vi.fn().mockResolvedValue(undefined),
|
||||
audioPlayed: vi.fn().mockResolvedValue(undefined),
|
||||
abortRequest: vi.fn().mockResolvedValue(undefined),
|
||||
setAssistantAudioPlaying: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAudioPayload(args: {
|
||||
id: string;
|
||||
groupId: string;
|
||||
chunkIndex: number;
|
||||
isLastChunk: boolean;
|
||||
isVoiceMode?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: args.id,
|
||||
groupId: args.groupId,
|
||||
chunkIndex: args.chunkIndex,
|
||||
isLastChunk: args.isLastChunk,
|
||||
isVoiceMode: args.isVoiceMode ?? true,
|
||||
format: "pcm",
|
||||
audio: Buffer.from(`chunk-${args.chunkIndex}`).toString("base64"),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createServerInfo(): DaemonServerInfo {
|
||||
return {
|
||||
serverId: "server-1",
|
||||
@@ -91,37 +108,21 @@ describe("voice runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions from capturing to submitting to waiting on the final chunk", async () => {
|
||||
it("streams continuous PCM chunks and waits after receiving a transcript", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.handleCaptureVolume(0.4);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10
|
||||
);
|
||||
runtime.handleCaptureVolume(0.4);
|
||||
runtime.handleCapturePcm(new Uint8Array(32000));
|
||||
expect(runtime.getSnapshot().phase).toBe("capturing");
|
||||
|
||||
runtime.handleCapturePcm(new Uint8Array(4000));
|
||||
runtime.handleCaptureVolume(0);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs + 10
|
||||
);
|
||||
runtime.handleCaptureVolume(0);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
REALTIME_VOICE_VAD_CONFIG.silenceDurationMs + 10
|
||||
);
|
||||
runtime.handleCaptureVolume(0);
|
||||
await Promise.resolve();
|
||||
expect(runtime.getSnapshot().phase).toBe("listening");
|
||||
|
||||
expect(adapter.sendVoiceAudioChunk).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
true
|
||||
"audio/pcm;rate=16000;bits=16"
|
||||
);
|
||||
|
||||
runtime.onTranscriptionResult("server-1", "hello");
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
});
|
||||
|
||||
@@ -134,10 +135,113 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("playing");
|
||||
expect(engine.stopLooping).toHaveBeenCalled();
|
||||
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("starts playback on the first chunk before isLastChunk arrives", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
let resolvePlay!: (duration: number) => void;
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.play).mockImplementation(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolvePlay = resolve;
|
||||
})
|
||||
);
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-0",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: false,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getSnapshot().phase).toBe("playing");
|
||||
expect(adapter.audioPlayed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-1",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 1,
|
||||
isLastChunk: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
resolvePlay(0.1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
|
||||
expect(engine.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("acknowledges chunks only after they are consumed and finishes after the final chunk", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const playResolvers: Array<(duration: number) => void> = [];
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.play).mockImplementation(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
playResolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-0",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: false,
|
||||
})
|
||||
);
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "chunk-1",
|
||||
groupId: "group-1",
|
||||
chunkIndex: 1,
|
||||
isLastChunk: true,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(adapter.audioPlayed).not.toHaveBeenCalled();
|
||||
|
||||
playResolvers.shift()!(0.1);
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
|
||||
expect(engine.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
playResolvers.shift()!(0.1);
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-1");
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to waiting after assistant playback when the turn is still active", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
@@ -149,7 +253,53 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
expect(engine.playLooping).toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the thinking tone when an agent turn begins before playback", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart the thinking tone from local detection jitter while waiting", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
let resolvePlay!: (duration: number) => void;
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.play).mockImplementation(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolvePlay = resolve;
|
||||
})
|
||||
);
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
|
||||
runtime.handleCaptureVolume(REALTIME_VOICE_VAD_CONFIG.volumeThreshold + 0.05);
|
||||
runtime.handleCaptureVolume(0);
|
||||
|
||||
expect(engine.stop).not.toHaveBeenCalled();
|
||||
expect(engine.clearQueue).not.toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", true);
|
||||
|
||||
expect(engine.stop).toHaveBeenCalledTimes(1);
|
||||
expect(engine.clearQueue).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePlay(0.1);
|
||||
});
|
||||
|
||||
it("returns to listening after assistant playback once the turn is complete", async () => {
|
||||
@@ -164,10 +314,10 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("listening");
|
||||
expect(engine.playLooping).not.toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts the active turn on barge-in after the grace period", async () => {
|
||||
it("keeps local volume alone non-authoritative for playback interruption", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
@@ -177,16 +327,45 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
|
||||
runtime.handleCaptureVolume(0.5);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs
|
||||
);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
|
||||
expect(adapter.abortRequest).not.toHaveBeenCalled();
|
||||
expect(engine.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the meter white state driven by server speech detection", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.handleCaptureVolume(0.7);
|
||||
|
||||
expect(runtime.getTelemetrySnapshot()).toMatchObject({
|
||||
volume: expect.any(Number),
|
||||
isSpeaking: false,
|
||||
});
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", true);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", false);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
|
||||
});
|
||||
|
||||
it("stops assistant playback immediately when server speech starts", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
|
||||
runtime.onServerSpeechStateChanged("server-1", true);
|
||||
|
||||
expect(adapter.abortRequest).toHaveBeenCalled();
|
||||
expect(engine.stop).toHaveBeenCalled();
|
||||
expect(runtime.getSnapshot().phase).toBe("capturing");
|
||||
expect(engine.clearQueue).toHaveBeenCalled();
|
||||
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(false);
|
||||
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
|
||||
});
|
||||
|
||||
it("authoritatively stops and suppresses later voice audio", async () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import { Buffer } from "buffer";
|
||||
import type {
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import type { DaemonServerInfo } from "@/stores/session-store";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
||||
import {
|
||||
THINKING_TONE_NATIVE_PCM_BASE64,
|
||||
THINKING_TONE_NATIVE_PCM_DURATION_MS,
|
||||
} from "@/utils/thinking-tone.native-pcm";
|
||||
|
||||
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
|
||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||
@@ -22,7 +29,6 @@ export type VoiceRuntimePhase =
|
||||
| "disabled"
|
||||
| "starting"
|
||||
| "listening"
|
||||
| "capturing"
|
||||
| "submitting"
|
||||
| "waiting"
|
||||
| "playing"
|
||||
@@ -39,7 +45,6 @@ export interface VoiceRuntimeSnapshot {
|
||||
|
||||
export interface VoiceRuntimeTelemetrySnapshot {
|
||||
volume: number;
|
||||
isDetecting: boolean;
|
||||
isSpeaking: boolean;
|
||||
segmentDuration: number;
|
||||
}
|
||||
@@ -47,11 +52,8 @@ export interface VoiceRuntimeTelemetrySnapshot {
|
||||
export interface VoiceSessionAdapter {
|
||||
serverId: string;
|
||||
setVoiceMode(enabled: boolean, agentId?: string): Promise<void>;
|
||||
sendVoiceAudioChunk(
|
||||
audioData: string,
|
||||
mimeType: string,
|
||||
isLast: boolean
|
||||
): Promise<void>;
|
||||
sendVoiceAudioChunk(audioData: string, mimeType: string): Promise<void>;
|
||||
audioPlayed(chunkId: string): Promise<void>;
|
||||
abortRequest(): Promise<void>;
|
||||
setAssistantAudioPlaying(isPlaying: boolean): void;
|
||||
}
|
||||
@@ -68,16 +70,73 @@ interface RuntimeSessionState {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
interface ContinuousVoiceUploader {
|
||||
reset(): void;
|
||||
pushPcmChunk(chunk: Uint8Array): void;
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
snapshot: VoiceRuntimeSnapshot;
|
||||
telemetry: VoiceRuntimeTelemetrySnapshot;
|
||||
turnInProgress: boolean;
|
||||
serverSpeechDetected: boolean;
|
||||
transportReady: boolean;
|
||||
generation: number;
|
||||
speechInterruptTimer: ReturnType<typeof setTimeout> | null;
|
||||
speechInterruptSent: boolean;
|
||||
segmentDurationTimer: ReturnType<typeof setInterval> | null;
|
||||
lastDisplayVolumePublishMs: number;
|
||||
serverSpeechStartedAt: number | null;
|
||||
lastNoServerSpeechLogMs: number;
|
||||
localAboveThresholdActive: boolean;
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "audio_output" }
|
||||
>["payload"];
|
||||
|
||||
type StreamingPlaybackChunk = {
|
||||
id: string;
|
||||
chunkIndex: number;
|
||||
source: { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string };
|
||||
};
|
||||
|
||||
type StreamingPlaybackGroup = {
|
||||
groupId: string;
|
||||
isVoiceMode: boolean;
|
||||
shouldPlay: boolean;
|
||||
chunks: Map<number, StreamingPlaybackChunk>;
|
||||
nextChunkToPlay: number;
|
||||
finalChunkIndex: number | null;
|
||||
started: boolean;
|
||||
ackedChunkIds: Set<string>;
|
||||
};
|
||||
|
||||
interface RuntimePlaybackState {
|
||||
groups: Map<string, StreamingPlaybackGroup>;
|
||||
orderedGroupIds: string[];
|
||||
activeGroupId: string | null;
|
||||
processing: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
interface CueState {
|
||||
active: boolean;
|
||||
token: number;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
interface RealtimeBridgeStats {
|
||||
windowStartedAtMs: number;
|
||||
captureEvents: number;
|
||||
captureBytes: number;
|
||||
uplinkEvents: number;
|
||||
uplinkRawBytes: number;
|
||||
uplinkBase64Chars: number;
|
||||
outputEvents: number;
|
||||
outputBytes: number;
|
||||
outputGroups: number;
|
||||
jsLagMaxMs: number;
|
||||
}
|
||||
|
||||
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
@@ -91,11 +150,12 @@ const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
|
||||
const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||
volume: 0,
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
segmentDuration: 0,
|
||||
};
|
||||
|
||||
let nextVoiceRuntimeInstanceId = 1;
|
||||
|
||||
function snapshotsEqual(
|
||||
left: VoiceRuntimeSnapshot,
|
||||
right: VoiceRuntimeSnapshot
|
||||
@@ -116,7 +176,6 @@ function telemetryEqual(
|
||||
): boolean {
|
||||
return (
|
||||
left.volume === right.volume &&
|
||||
left.isDetecting === right.isDetecting &&
|
||||
left.isSpeaking === right.isSpeaking &&
|
||||
left.segmentDuration === right.segmentDuration
|
||||
);
|
||||
@@ -131,6 +190,7 @@ export interface VoiceRuntime {
|
||||
updateSessionConnection(serverId: string, connected: boolean): void;
|
||||
handleCapturePcm(chunk: Uint8Array): void;
|
||||
handleCaptureVolume(level: number): void;
|
||||
handleAudioOutput(serverId: string, payload: AudioOutputPayload): void;
|
||||
startVoice(serverId: string, agentId: string): Promise<void>;
|
||||
stopVoice(): Promise<void>;
|
||||
destroy(): Promise<void>;
|
||||
@@ -140,10 +200,12 @@ export interface VoiceRuntime {
|
||||
onAssistantAudioStarted(serverId: string): void;
|
||||
onAssistantAudioFinished(serverId: string): void;
|
||||
onTranscriptionResult(serverId: string, text: string): void;
|
||||
onServerSpeechStateChanged(serverId: string, isSpeaking: boolean): void;
|
||||
onTurnEvent(serverId: string, agentId: string, eventType: TurnEventType): void;
|
||||
}
|
||||
|
||||
export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
const instanceId = nextVoiceRuntimeInstanceId++;
|
||||
const listeners = new Set<() => void>();
|
||||
const telemetryListeners = new Set<() => void>();
|
||||
const sessions = new Map<string, RuntimeSessionState>();
|
||||
@@ -151,13 +213,85 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
snapshot: INITIAL_SNAPSHOT,
|
||||
telemetry: INITIAL_TELEMETRY,
|
||||
turnInProgress: false,
|
||||
serverSpeechDetected: false,
|
||||
transportReady: false,
|
||||
generation: 0,
|
||||
speechInterruptTimer: null,
|
||||
speechInterruptSent: false,
|
||||
segmentDurationTimer: null,
|
||||
lastDisplayVolumePublishMs: 0,
|
||||
serverSpeechStartedAt: null,
|
||||
lastNoServerSpeechLogMs: 0,
|
||||
localAboveThresholdActive: false,
|
||||
};
|
||||
const playback: RuntimePlaybackState = {
|
||||
groups: new Map(),
|
||||
orderedGroupIds: [],
|
||||
activeGroupId: null,
|
||||
processing: false,
|
||||
generation: 0,
|
||||
};
|
||||
const bridgeStats: RealtimeBridgeStats = {
|
||||
windowStartedAtMs: Date.now(),
|
||||
captureEvents: 0,
|
||||
captureBytes: 0,
|
||||
uplinkEvents: 0,
|
||||
uplinkRawBytes: 0,
|
||||
uplinkBase64Chars: 0,
|
||||
outputEvents: 0,
|
||||
outputBytes: 0,
|
||||
outputGroups: 0,
|
||||
jsLagMaxMs: 0,
|
||||
};
|
||||
const cue: CueState = {
|
||||
active: false,
|
||||
token: 0,
|
||||
timeout: null,
|
||||
playing: false,
|
||||
};
|
||||
const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64"));
|
||||
const cueSource = {
|
||||
size: cuePcm16.byteLength,
|
||||
type: "audio/pcm;rate=16000;bits=16",
|
||||
async arrayBuffer() {
|
||||
return cuePcm16.buffer.slice(
|
||||
cuePcm16.byteOffset,
|
||||
cuePcm16.byteOffset + cuePcm16.byteLength
|
||||
);
|
||||
},
|
||||
};
|
||||
let lagProbeLastMs = Date.now();
|
||||
const lagProbe = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const lagMs = Math.max(0, now - lagProbeLastMs - 100);
|
||||
lagProbeLastMs = now;
|
||||
if (lagMs > bridgeStats.jsLagMaxMs) {
|
||||
bridgeStats.jsLagMaxMs = lagMs;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
function flushBridgeStats(reason: string): void {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bridgeStats.windowStartedAtMs;
|
||||
if (elapsedMs < 1000) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}][bridge] ${reason} ` +
|
||||
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
||||
`uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` +
|
||||
`output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` +
|
||||
`jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`
|
||||
);
|
||||
bridgeStats.windowStartedAtMs = now;
|
||||
bridgeStats.captureEvents = 0;
|
||||
bridgeStats.captureBytes = 0;
|
||||
bridgeStats.uplinkEvents = 0;
|
||||
bridgeStats.uplinkRawBytes = 0;
|
||||
bridgeStats.uplinkBase64Chars = 0;
|
||||
bridgeStats.outputEvents = 0;
|
||||
bridgeStats.outputBytes = 0;
|
||||
bridgeStats.outputGroups = 0;
|
||||
bridgeStats.jsLagMaxMs = 0;
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
@@ -213,10 +347,151 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
return sessions.get(state.snapshot.activeServerId) ?? null;
|
||||
}
|
||||
|
||||
function clearSpeechInterruptTimer(): void {
|
||||
if (state.speechInterruptTimer) {
|
||||
clearTimeout(state.speechInterruptTimer);
|
||||
state.speechInterruptTimer = null;
|
||||
function decodeAudioChunk(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
function toPlaybackSource(
|
||||
bytes: Uint8Array,
|
||||
format: string
|
||||
): { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string } {
|
||||
const mimeType =
|
||||
format === "pcm"
|
||||
? "audio/pcm;rate=24000;bits=16"
|
||||
: format === "mp3"
|
||||
? "audio/mpeg"
|
||||
: `audio/${format}`;
|
||||
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
type: mimeType,
|
||||
async arrayBuffer() {
|
||||
return Uint8Array.from(bytes).buffer;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetPlaybackState(): void {
|
||||
const hadGroups = playback.groups.size;
|
||||
playback.generation += 1;
|
||||
playback.groups.clear();
|
||||
playback.orderedGroupIds = [];
|
||||
playback.activeGroupId = null;
|
||||
playback.processing = false;
|
||||
if (hadGroups > 0) {
|
||||
console.log(`[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`);
|
||||
}
|
||||
}
|
||||
|
||||
function activateNextPlaybackGroup(): void {
|
||||
while (playback.orderedGroupIds.length > 0) {
|
||||
const groupId = playback.orderedGroupIds[0]!;
|
||||
if (playback.groups.has(groupId)) {
|
||||
playback.activeGroupId = groupId;
|
||||
return;
|
||||
}
|
||||
playback.orderedGroupIds.shift();
|
||||
}
|
||||
playback.activeGroupId = null;
|
||||
}
|
||||
|
||||
async function acknowledgeChunk(chunkId: string): Promise<void> {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
await activeSession.adapter.audioPlayed(chunkId);
|
||||
}
|
||||
|
||||
async function processPlaybackQueue(serverId: string): Promise<void> {
|
||||
if (playback.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
playback.processing = true;
|
||||
const generation = playback.generation;
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`);
|
||||
try {
|
||||
while (playback.activeGroupId) {
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = playback.groups.get(playback.activeGroupId);
|
||||
if (!group) {
|
||||
activateNextPlaybackGroup();
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChunk = group.chunks.get(group.nextChunkToPlay);
|
||||
if (!nextChunk) {
|
||||
if (
|
||||
group.finalChunkIndex !== null &&
|
||||
group.nextChunkToPlay > group.finalChunkIndex
|
||||
) {
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`);
|
||||
playback.groups.delete(group.groupId);
|
||||
if (playback.orderedGroupIds[0] === group.groupId) {
|
||||
playback.orderedGroupIds.shift();
|
||||
} else {
|
||||
playback.orderedGroupIds = playback.orderedGroupIds.filter(
|
||||
(value) => value !== group.groupId
|
||||
);
|
||||
}
|
||||
if (group.started && group.isVoiceMode) {
|
||||
api.onAssistantAudioFinished(serverId);
|
||||
}
|
||||
activateNextPlaybackGroup();
|
||||
continue;
|
||||
}
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`);
|
||||
return;
|
||||
}
|
||||
|
||||
group.chunks.delete(group.nextChunkToPlay);
|
||||
|
||||
if (group.shouldPlay && !group.started && group.isVoiceMode) {
|
||||
group.started = true;
|
||||
console.log(`[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`);
|
||||
api.onAssistantAudioStarted(serverId);
|
||||
}
|
||||
|
||||
const playStart = Date.now();
|
||||
try {
|
||||
if (group.shouldPlay) {
|
||||
await deps.engine.play(nextChunk.source);
|
||||
console.log(`[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`);
|
||||
} else {
|
||||
console.log(`[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] play error + generation changed, aborting`);
|
||||
return;
|
||||
}
|
||||
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`, error);
|
||||
}
|
||||
|
||||
if (generation !== playback.generation) {
|
||||
console.log(`[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!group.ackedChunkIds.has(nextChunk.id)) {
|
||||
group.ackedChunkIds.add(nextChunk.id);
|
||||
void acknowledgeChunk(nextChunk.id).catch((error) => {
|
||||
console.warn("[VoiceRuntime] Failed to confirm audio playback:", error);
|
||||
});
|
||||
}
|
||||
|
||||
group.nextChunkToPlay += 1;
|
||||
}
|
||||
} finally {
|
||||
if (generation === playback.generation) {
|
||||
playback.processing = false;
|
||||
}
|
||||
console.log(`[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +502,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileSegmentDurationTimer(
|
||||
segmenter: SpeechSegmenter
|
||||
): void {
|
||||
if (!state.telemetry.isDetecting && !state.telemetry.isSpeaking) {
|
||||
function reconcileSegmentDurationTimer(): void {
|
||||
if (!state.serverSpeechDetected) {
|
||||
clearSegmentDurationTimer();
|
||||
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
||||
return;
|
||||
@@ -241,7 +514,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
state.segmentDurationTimer = setInterval(() => {
|
||||
const startedAt = segmenter.getSpeechDetectionStartMs();
|
||||
const startedAt = state.serverSpeechStartedAt;
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
segmentDuration: startedAt ? Date.now() - startedAt : 0,
|
||||
@@ -253,19 +526,27 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
return (
|
||||
state.snapshot.isVoiceMode &&
|
||||
state.snapshot.phase === "waiting" &&
|
||||
!state.telemetry.isDetecting &&
|
||||
!state.telemetry.isSpeaking
|
||||
);
|
||||
}
|
||||
|
||||
function stopCue(): void {
|
||||
deps.engine.stopLooping();
|
||||
const hadActive = cue.active || cue.timeout !== null || cue.playing;
|
||||
cue.active = false;
|
||||
cue.token += 1;
|
||||
if (cue.timeout) {
|
||||
clearTimeout(cue.timeout);
|
||||
cue.timeout = null;
|
||||
}
|
||||
if (hadActive) {
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function resetSegmenter(segmenter: SpeechSegmenter): void {
|
||||
segmenter.reset();
|
||||
clearSpeechInterruptTimer();
|
||||
function resetCaptureTelemetry(): void {
|
||||
clearSegmentDurationTimer();
|
||||
state.serverSpeechStartedAt = null;
|
||||
patchTelemetry({ ...INITIAL_TELEMETRY });
|
||||
}
|
||||
|
||||
@@ -274,137 +555,76 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
return;
|
||||
}
|
||||
deps.engine.playLooping(new Uint8Array(0), THINKING_TONE_REPEAT_GAP_MS);
|
||||
}
|
||||
|
||||
async function interruptActiveTurn(): Promise<void> {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession) {
|
||||
if (cue.active) {
|
||||
return;
|
||||
}
|
||||
cue.active = true;
|
||||
cue.token += 1;
|
||||
const token = cue.token;
|
||||
|
||||
stopCue();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
activeSession.adapter.setAssistantAudioPlaying(false);
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "capturing" }));
|
||||
const playNext = () => {
|
||||
if (!cue.active || cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
cue.playing = true;
|
||||
void deps.engine
|
||||
.play(cueSource)
|
||||
.catch((error) => {
|
||||
if (cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
cue.playing = false;
|
||||
if (!cue.active || cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
cue.timeout = setTimeout(playNext, THINKING_TONE_NATIVE_PCM_DURATION_MS + THINKING_TONE_REPEAT_GAP_MS);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await activeSession.adapter.abortRequest();
|
||||
} catch (error) {
|
||||
console.error("[VoiceRuntime] Failed to abort active turn:", error);
|
||||
}
|
||||
playNext();
|
||||
}
|
||||
|
||||
const segmenter = new SpeechSegmenter(
|
||||
{
|
||||
enableContinuousStreaming: false,
|
||||
volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold,
|
||||
confirmedDropGracePeriodMs:
|
||||
REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs,
|
||||
silenceDurationMs: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs,
|
||||
speechConfirmationMs: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs,
|
||||
detectionGracePeriodMs: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs,
|
||||
const uploader: ContinuousVoiceUploader = {
|
||||
reset() {},
|
||||
pushPcmChunk(chunk) {
|
||||
const activeSession = getActiveSession();
|
||||
if (
|
||||
!activeSession ||
|
||||
!state.transportReady ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
chunk.byteLength === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(chunk).toString("base64");
|
||||
bridgeStats.captureEvents += 1;
|
||||
bridgeStats.captureBytes += chunk.byteLength;
|
||||
bridgeStats.uplinkEvents += 1;
|
||||
bridgeStats.uplinkRawBytes += chunk.byteLength;
|
||||
bridgeStats.uplinkBase64Chars += base64.length;
|
||||
flushBridgeStats("uplink");
|
||||
|
||||
void activeSession.adapter
|
||||
.sendVoiceAudioChunk(base64, PCM_MIME_TYPE)
|
||||
.catch((error) => {
|
||||
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
|
||||
});
|
||||
},
|
||||
{
|
||||
onAudioSegment: ({ audioData, isLast }) => {
|
||||
const activeSession = getActiveSession();
|
||||
if (!activeSession || !state.transportReady || !state.snapshot.isVoiceMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = state.generation;
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
phase: isLast ? "submitting" : "capturing",
|
||||
}));
|
||||
if (isLast) {
|
||||
state.turnInProgress = true;
|
||||
}
|
||||
|
||||
void activeSession.adapter
|
||||
.sendVoiceAudioChunk(audioData, PCM_MIME_TYPE, isLast)
|
||||
.then(() => {
|
||||
if (
|
||||
!isLast ||
|
||||
generation !== state.generation ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
state.snapshot.activeServerId !== activeSession.adapter.serverId ||
|
||||
state.snapshot.phase !== "submitting"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[VoiceRuntime] Failed to send audio segment:", error);
|
||||
});
|
||||
},
|
||||
onSpeechStart: () => {
|
||||
stopCue();
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptSent = false;
|
||||
reconcileCue();
|
||||
},
|
||||
onDetectingChange: (isDetecting) => {
|
||||
const previous = state.telemetry;
|
||||
patchTelemetry((prev) => ({ ...prev, isDetecting }));
|
||||
if (previous.isDetecting !== isDetecting) {
|
||||
}
|
||||
if (!previous.isDetecting && isDetecting) {
|
||||
stopCue();
|
||||
}
|
||||
reconcileSegmentDurationTimer(segmenter);
|
||||
reconcileCue();
|
||||
},
|
||||
onSpeakingChange: (isSpeaking) => {
|
||||
const previous = state.telemetry;
|
||||
patchTelemetry((prev) => ({ ...prev, isSpeaking }));
|
||||
if (previous.isSpeaking !== isSpeaking) {
|
||||
}
|
||||
|
||||
if (!previous.isSpeaking && isSpeaking) {
|
||||
stopCue();
|
||||
if (
|
||||
state.snapshot.phase === "waiting" ||
|
||||
state.snapshot.phase === "playing"
|
||||
) {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptTimer = setTimeout(() => {
|
||||
state.speechInterruptTimer = null;
|
||||
if (
|
||||
state.speechInterruptSent ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
!state.telemetry.isSpeaking
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.speechInterruptSent = true;
|
||||
void interruptActiveTurn();
|
||||
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSpeaking) {
|
||||
clearSpeechInterruptTimer();
|
||||
state.speechInterruptSent = false;
|
||||
}
|
||||
|
||||
reconcileSegmentDurationTimer(segmenter);
|
||||
reconcileCue();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function resetToDisabledState(): void {
|
||||
state.transportReady = false;
|
||||
state.turnInProgress = false;
|
||||
state.speechInterruptSent = false;
|
||||
state.serverSpeechDetected = false;
|
||||
state.lastDisplayVolumePublishMs = 0;
|
||||
resetSegmenter(segmenter);
|
||||
state.lastNoServerSpeechLogMs = 0;
|
||||
state.localAboveThresholdActive = false;
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot({ ...INITIAL_SNAPSHOT });
|
||||
}
|
||||
|
||||
@@ -435,6 +655,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
async function performLocalStop(): Promise<void> {
|
||||
stopCue();
|
||||
uploader.reset();
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
await deps.engine.stopCapture().catch(() => undefined);
|
||||
@@ -466,7 +688,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const api: VoiceRuntime = {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -524,7 +746,12 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||
return;
|
||||
}
|
||||
segmenter.pushPcmChunk(chunk);
|
||||
if (bridgeStats.captureEvents === 0) {
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`
|
||||
);
|
||||
}
|
||||
uploader.pushPcmChunk(chunk);
|
||||
},
|
||||
|
||||
handleCaptureVolume(level) {
|
||||
@@ -532,9 +759,100 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
const displayLevel = state.snapshot.isMuted ? 0 : level;
|
||||
publishDisplayVolume(displayLevel, nowMs);
|
||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isSpeaking: false,
|
||||
segmentDuration: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
segmenter.pushVolumeLevel(level, nowMs);
|
||||
|
||||
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
|
||||
if (isActive && !state.localAboveThresholdActive) {
|
||||
state.localAboveThresholdActive = true;
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`
|
||||
);
|
||||
}
|
||||
if (!isActive && state.localAboveThresholdActive) {
|
||||
state.localAboveThresholdActive = false;
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
isActive &&
|
||||
!state.serverSpeechDetected &&
|
||||
nowMs - state.lastNoServerSpeechLogMs >= 1500
|
||||
) {
|
||||
state.lastNoServerSpeechLogMs = nowMs;
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`
|
||||
);
|
||||
}
|
||||
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isSpeaking: state.serverSpeechDetected,
|
||||
}));
|
||||
reconcileSegmentDurationTimer();
|
||||
reconcileCue();
|
||||
},
|
||||
|
||||
handleAudioOutput(serverId, payload) {
|
||||
if (
|
||||
serverId !== state.snapshot.activeServerId ||
|
||||
!state.snapshot.isVoiceMode ||
|
||||
!payload.isVoiceMode
|
||||
) {
|
||||
console.log(`[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
const decoded = decodeAudioChunk(payload.audio);
|
||||
bridgeStats.outputEvents += 1;
|
||||
bridgeStats.outputBytes += decoded.byteLength;
|
||||
bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1;
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` +
|
||||
`base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` +
|
||||
`head=${Array.from(decoded.slice(0, 12)).map((value) => value.toString(16).padStart(2, "0")).join(" ")}`
|
||||
);
|
||||
flushBridgeStats("audio_output");
|
||||
|
||||
let group = playback.groups.get(groupId);
|
||||
if (!group) {
|
||||
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
|
||||
console.log(`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`);
|
||||
group = {
|
||||
groupId,
|
||||
isVoiceMode: payload.isVoiceMode,
|
||||
shouldPlay: api.shouldPlayVoiceAudio(serverId),
|
||||
chunks: new Map(),
|
||||
nextChunkToPlay: 0,
|
||||
finalChunkIndex: null,
|
||||
started: false,
|
||||
ackedChunkIds: new Set(),
|
||||
};
|
||||
playback.groups.set(groupId, group);
|
||||
playback.orderedGroupIds.push(groupId);
|
||||
if (!playback.activeGroupId) {
|
||||
playback.activeGroupId = groupId;
|
||||
}
|
||||
}
|
||||
|
||||
group.chunks.set(chunkIndex, {
|
||||
id: payload.id,
|
||||
chunkIndex,
|
||||
source: toPlaybackSource(decoded, payload.format),
|
||||
});
|
||||
if (payload.isLastChunk) {
|
||||
group.finalChunkIndex = chunkIndex;
|
||||
}
|
||||
|
||||
void processPlaybackQueue(serverId);
|
||||
},
|
||||
|
||||
async startVoice(serverId, agentId) {
|
||||
@@ -594,7 +912,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
state.transportReady = true;
|
||||
state.turnInProgress = false;
|
||||
resetSegmenter(segmenter);
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
isVoiceMode: true,
|
||||
@@ -619,8 +938,10 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}));
|
||||
|
||||
try {
|
||||
state.transportReady = false;
|
||||
stopCue();
|
||||
uploader.reset();
|
||||
state.transportReady = false;
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
activeSession?.adapter.setAssistantAudioPlaying(false);
|
||||
@@ -638,6 +959,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
async destroy() {
|
||||
await this.stopVoice().catch(() => undefined);
|
||||
clearInterval(lagProbe);
|
||||
await deps.engine.destroy();
|
||||
listeners.clear();
|
||||
telemetryListeners.clear();
|
||||
@@ -647,7 +969,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
toggleMute() {
|
||||
const nextMuted = deps.engine.toggleMute();
|
||||
if (nextMuted) {
|
||||
resetSegmenter(segmenter);
|
||||
uploader.reset();
|
||||
resetCaptureTelemetry();
|
||||
patchSnapshot((prev) => ({
|
||||
...prev,
|
||||
isMuted: true,
|
||||
@@ -672,9 +995,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
state.snapshot.isVoiceMode &&
|
||||
state.snapshot.activeServerId === serverId &&
|
||||
state.snapshot.phase !== "stopping" &&
|
||||
state.snapshot.phase !== "disabled" &&
|
||||
!state.telemetry.isDetecting &&
|
||||
!state.telemetry.isSpeaking
|
||||
state.snapshot.phase !== "disabled"
|
||||
);
|
||||
},
|
||||
|
||||
@@ -701,8 +1022,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
if (state.turnInProgress) {
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,6 +1038,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
if (text.trim()) {
|
||||
state.turnInProgress = true;
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -727,6 +1049,33 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
},
|
||||
|
||||
onServerSpeechStateChanged(serverId, isSpeaking) {
|
||||
if (
|
||||
serverId !== state.snapshot.activeServerId ||
|
||||
!state.snapshot.isVoiceMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`
|
||||
);
|
||||
state.serverSpeechDetected = isSpeaking;
|
||||
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
|
||||
if (isSpeaking) {
|
||||
resetPlaybackState();
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
getActiveSession()?.adapter.setAssistantAudioPlaying(false);
|
||||
}
|
||||
patchTelemetry((prev) => ({
|
||||
...prev,
|
||||
isSpeaking,
|
||||
}));
|
||||
reconcileSegmentDurationTimer();
|
||||
reconcileCue();
|
||||
},
|
||||
|
||||
onTurnEvent(serverId, agentId, eventType) {
|
||||
if (
|
||||
!state.snapshot.isVoiceMode ||
|
||||
@@ -738,6 +1087,10 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
if (eventType === "turn_started") {
|
||||
state.turnInProgress = true;
|
||||
if (state.snapshot.phase !== "playing") {
|
||||
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
|
||||
reconcileCue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -748,4 +1101,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -27,15 +27,29 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@server": path.resolve(__dirname, "../server/src"),
|
||||
alias: [
|
||||
{
|
||||
find: /^@getpaseo\/relay\/e2ee$/,
|
||||
replacement: path.resolve(__dirname, "../relay/src/e2ee.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@getpaseo\/relay$/,
|
||||
replacement: path.resolve(__dirname, "../relay/src/index.ts"),
|
||||
},
|
||||
{ find: "@", replacement: path.resolve(__dirname, "src") },
|
||||
{ find: "@server", replacement: path.resolve(__dirname, "../server/src") },
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
"react-native": path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||
react: path.resolve(appNodeModules, "react"),
|
||||
"react-dom": path.resolve(appNodeModules, "react-dom"),
|
||||
},
|
||||
{
|
||||
find: "react-native",
|
||||
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||
},
|
||||
{ find: "react", replacement: path.resolve(appNodeModules, "react") },
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: path.resolve(appNodeModules, "react-dom"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.29",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.27",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"@getpaseo/relay": "0.1.29",
|
||||
"@getpaseo/server": "0.1.29",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -75,7 +75,7 @@ export function createCli(): Command {
|
||||
.argument('<prompt>', 'The task/prompt for the agent')
|
||||
.option('-d, --detach', 'Run in background (detached)')
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
@@ -133,7 +133,9 @@ export function createCli(): Command {
|
||||
.command('send')
|
||||
.description('Send a message/task to an existing agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('<prompt>', 'The message to send')
|
||||
.argument('[prompt]', 'The message to send')
|
||||
.option('--prompt <text>', 'Provide the message inline as a flag')
|
||||
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
|
||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
||||
).action(withOutput(runSendCommand))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
|
||||
/** Result type for agent archive command */
|
||||
@@ -38,7 +38,7 @@ export async function runArchiveCommand(
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent archive <id>',
|
||||
details: 'Usage: paseo agent archive <id-or-name>',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -57,8 +57,10 @@ export async function runArchiveCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||
if (!fetchResult) {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||
const agentId = resolveAgentId(agentIdArg, agents)
|
||||
if (!agentId) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
@@ -66,8 +68,10 @@ export async function runArchiveCommand(
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const agent = fetchResult.agent
|
||||
const agentId = agent.id
|
||||
const agent = agents.find((entry) => entry.id === agentId)
|
||||
if (!agent) {
|
||||
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`)
|
||||
}
|
||||
|
||||
// Check if agent is already archived
|
||||
if (agent.archivedAt) {
|
||||
|
||||
@@ -39,12 +39,16 @@ export function createAgentCommand(): Command {
|
||||
.argument('<prompt>', 'The task/prompt for the agent')
|
||||
.option('-d, --detach', 'Run in background (detached)')
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
.option(
|
||||
'--wait-timeout <duration>',
|
||||
'Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit'
|
||||
)
|
||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||
).action(withOutput(runRunCommand))
|
||||
|
||||
@@ -88,7 +92,9 @@ export function createAgentCommand(): Command {
|
||||
.command('send')
|
||||
.description('Send a message/task to an existing agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('<prompt>', 'The message to send')
|
||||
.argument('[prompt]', 'The message to send')
|
||||
.option('--prompt <text>', 'Provide the message inline as a flag')
|
||||
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
|
||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||
).action(withOutput(runSendCommand))
|
||||
|
||||
@@ -121,7 +127,7 @@ export function createAgentCommand(): Command {
|
||||
agent
|
||||
.command('archive')
|
||||
.description('Archive an agent (soft-delete)')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('<id>', 'Agent ID, prefix, or name')
|
||||
.option('--force', 'Force archive running agent (interrupts active run first)')
|
||||
).action(withOutput(runArchiveCommand))
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user