test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -14,8 +14,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
|
||||
@@ -4,20 +4,16 @@ import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const marker = 'TIMELINE_HYDRATION_OK';
|
||||
const prompt = `Respond with exactly: ${marker}`;
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: marker })
|
||||
.first();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
@@ -25,9 +21,6 @@ test('agent timeline hydrates after reload via fetch_agent_timeline_request', as
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
@@ -12,6 +13,10 @@ 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();
|
||||
}
|
||||
@@ -20,6 +25,26 @@ 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');
|
||||
@@ -32,6 +57,11 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
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();
|
||||
@@ -42,16 +72,15 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
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 openChangesOverflowMenu(page: Page) {
|
||||
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
@@ -62,25 +91,10 @@ async function openChangesPrimaryMenu(page: Page) {
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
const changesTab = explorerHeader.getByText('Changes', { exact: true });
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
await changesTab.click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
@@ -95,98 +109,62 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPrompt(page: Page, prompt: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
async function waitForAssistantText(page: Page, text: string) {
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
|
||||
return assistantMessage;
|
||||
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) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function requestCwd(page: Page) {
|
||||
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
||||
const message = await waitForAssistantText(page, 'CWD:');
|
||||
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
|
||||
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('/worktrees/');
|
||||
const content = (await message.textContent()) ?? '';
|
||||
const match = content.match(/CWD:\s*(\S+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
||||
}
|
||||
return match[1].trim();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
const picker = page.getByTestId('worktree-attach-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
// Wait a bit for the worktree list to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await picker.click();
|
||||
|
||||
// Wait a bit for animation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const backdropVisible = await backdrop.isVisible().catch(() => false);
|
||||
// Also check if branch name is visible directly
|
||||
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
|
||||
return sheetVisible || backdropVisible || branchVisible;
|
||||
}, { timeout: 10000 }).toBeTruthy();
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const scope = sheetVisible ? sheet : page;
|
||||
const preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||
if (await preferredOption.isVisible().catch(() => false)) {
|
||||
await preferredOption.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
return;
|
||||
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 options = scope.locator('[data-testid^="worktree-attach-option-"]');
|
||||
const optionCount = await options.count();
|
||||
if (optionCount === 0) {
|
||||
throw new Error(`No worktree options were available in the attach picker`);
|
||||
}
|
||||
const fallbackOption = options.first();
|
||||
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
|
||||
await fallbackOption.click();
|
||||
if (fallbackLabel.length > 0) {
|
||||
await expect(picker).toContainText(fallbackLabel);
|
||||
}
|
||||
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 createToggle = page.getByTestId('worktree-create-toggle');
|
||||
const willCreateLabel = page.getByText(/Will create:/);
|
||||
if (await willCreateLabel.isVisible()) {
|
||||
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;
|
||||
}
|
||||
const readyLabel = page.getByText(
|
||||
/Run isolated from|Run in an isolated directory/
|
||||
);
|
||||
await expect(readyLabel).toBeVisible({ timeout: 30000 });
|
||||
await createToggle.click({ force: true });
|
||||
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
|
||||
|
||||
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) {
|
||||
@@ -195,9 +173,9 @@ async function refreshUncommittedMode(page: Page) {
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
const header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await header.getByText('Files', { exact: true }).first().click();
|
||||
await header.getByText('Changes', { exact: true }).first().click();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
@@ -207,6 +185,72 @@ function normalizeTmpPath(value: string) {
|
||||
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-'));
|
||||
@@ -218,7 +262,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAssistantText(page, 'READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
@@ -228,11 +272,9 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const firstCwd = await requestCwd(page);
|
||||
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
@@ -244,19 +286,14 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
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 waitForAssistantText(page, 'READY2');
|
||||
|
||||
const secondCwd = await requestCwd(page);
|
||||
expect(secondCwd).toBe(firstCwd);
|
||||
|
||||
await sendPrompt(page, "Respond with exactly: OK");
|
||||
await waitForAssistantText(page, "OK");
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
@@ -384,43 +421,18 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
await openChangesOverflowMenu(page);
|
||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||
// Archiving a worktree deletes agents and redirects to home
|
||||
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
// Repo inspection is async; wait until git options are interactive again.
|
||||
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-picker').click();
|
||||
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
|
||||
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
if (await attachSheet.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('dropdown-sheet-close').click({ force: true });
|
||||
await expect(attachSheet).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||
// 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 setWorkingDirectory(page, nonGitDir);
|
||||
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
|
||||
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||
await waitForAssistantText(page, 'NON-GIT');
|
||||
await openChangesPanel(page, { expectGit: false });
|
||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -2,6 +2,18 @@ import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
function parseAgentUrl(url: string): { serverId: string; agentId: string } {
|
||||
const parsed = new URL(url);
|
||||
const match = parsed.pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
|
||||
}
|
||||
return {
|
||||
serverId: decodeURIComponent(match[1]),
|
||||
agentId: decodeURIComponent(match[2]),
|
||||
};
|
||||
}
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
@@ -15,14 +27,18 @@ test('create agent in a temp repo', async ({ page }) => {
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
await page.getByTestId('agent-overflow-menu').click();
|
||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('agent-overflow-content').getByText(/gpt-5\.1-codex-mini/i)
|
||||
).toBeVisible();
|
||||
|
||||
// Wait for agent response containing "Hello" within an assistant message
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
|
||||
// Verify the created agent's title reflects the response.
|
||||
const { serverId, agentId } = parseAgentUrl(page.url());
|
||||
const agentRow = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
||||
await expect(agentRow).not.toContainText(/new agent/i, { timeout: 30000 });
|
||||
await expect(agentRow).toContainText(/hello|greet|response/i, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -17,15 +17,19 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
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(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
@@ -36,11 +40,16 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||
// 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 });
|
||||
|
||||
@@ -5,10 +5,14 @@ import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/webm;codecs=opus';
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
@@ -39,6 +43,24 @@ async function addFakeMicrophone(page: Page) {
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -142,6 +164,9 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -199,7 +224,14 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/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]!);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
@@ -211,16 +243,19 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
const agentEntry = page.getByText(repo.path).first();
|
||||
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
||||
await expect(agentEntry).toBeVisible();
|
||||
await agentEntry.click();
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/agent/${escapeRegex(agentId)}(?:\\?|$)`)
|
||||
);
|
||||
|
||||
await expect(page.getByText(/voice note/i)).not.toBeVisible();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ test("draft enables explorer after selecting a working directory", async ({ page
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ test.beforeEach(async ({ page }) => {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
@@ -10,18 +10,31 @@ 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())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
await page.getByText('Changes', { exact: true }).click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText('View Changes', { exact: true }).click();
|
||||
}
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
@@ -31,22 +44,29 @@ async function refreshUncommittedMode(page: 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();
|
||||
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();
|
||||
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) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
|
||||
+428
-158
@@ -7,6 +7,14 @@ import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
type WaitForServerOptions = {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
childProcess?: ChildProcess | null;
|
||||
getRecentOutput?: () => string;
|
||||
};
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -22,23 +30,157 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
add(line: string) {
|
||||
lines.push(line);
|
||||
if (lines.length > maxLines) {
|
||||
lines.shift();
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return '';
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return '';
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const {
|
||||
host = '127.0.0.1',
|
||||
timeoutMs = 15000,
|
||||
label,
|
||||
childProcess,
|
||||
getRecentOutput,
|
||||
} = options;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, 'localhost', () => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/failed: ::bind\(/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/Fatal uncaught/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return 'empty response body';
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 240)}…`;
|
||||
}
|
||||
|
||||
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
|
||||
const key = apiKey?.trim();
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
@@ -81,165 +223,293 @@ export default async function globalSetup() {
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const relayPort = await getAvailablePort();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForServer(relayPort, 30000);
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[metro] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[daemon] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port),
|
||||
waitForServer(metroPort, 120000), // Metro can take longer to start
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
if (daemonProcess) {
|
||||
daemonProcess.kill('SIGTERM');
|
||||
daemonProcess = null;
|
||||
}
|
||||
if (metroProcess) {
|
||||
metroProcess.kill('SIGTERM');
|
||||
metroProcess = null;
|
||||
}
|
||||
if (relayProcess) {
|
||||
relayProcess.kill('SIGTERM');
|
||||
relayProcess = null;
|
||||
}
|
||||
const cleanup = async () => {
|
||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
||||
|
||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||
);
|
||||
|
||||
try {
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
relayPort = await getAvailablePort();
|
||||
relayLineBuffer = createLineBuffer();
|
||||
let relayStartupFailureLine: string | null = null;
|
||||
let relayReadyForSelectedPort = false;
|
||||
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stdout] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stderr] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: 'Relay dev server',
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
});
|
||||
|
||||
const readyDeadline = Date.now() + 5000;
|
||||
while (
|
||||
!relayReadyForSelectedPort &&
|
||||
relayStartupFailureLine === null &&
|
||||
relayProcess?.exitCode === null &&
|
||||
relayProcess?.signalCode === null &&
|
||||
Date.now() < readyDeadline
|
||||
) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (relayStartupFailureLine) {
|
||||
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
||||
}
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
relayStarted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
relayProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!relayStarted) {
|
||||
const message =
|
||||
lastRelayStartupError instanceof Error
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: '1',
|
||||
PASEO_VOICE_MODE_ENABLED: '1',
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: 'Paseo daemon',
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: 'Metro web server',
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+149
-20
@@ -71,10 +71,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
@@ -362,16 +362,83 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
|
||||
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 30000 });
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page.getByTestId('draft-provider-select').first();
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
||||
let selected = false;
|
||||
for (const label of preferredLabels) {
|
||||
const option = menu
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const options = menu.getByRole('button');
|
||||
const count = await options.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const option = options.nth(index);
|
||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (label.toLowerCase() === currentThinkingLabel) {
|
||||
continue;
|
||||
}
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
directory: string;
|
||||
provider?: string;
|
||||
@@ -381,42 +448,104 @@ export interface AgentConfig {
|
||||
}
|
||||
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error('Provider must be a non-empty string.');
|
||||
}
|
||||
|
||||
const option = page.getByText(provider, { exact: true }).first();
|
||||
const providerTrigger = page.getByTestId('draft-provider-select').first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole('dialog').last();
|
||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
throw new Error('Model must be a non-empty string.');
|
||||
}
|
||||
|
||||
const modelTrigger = page.getByTestId('draft-model-select').first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(model);
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 30000 });
|
||||
await option.click({ force: true });
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
await exactOption.click({ force: true });
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
const modeTrigger = page.getByTestId('draft-mode-select').first();
|
||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||
await modeTrigger.click();
|
||||
} else {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
|
||||
@@ -12,7 +12,9 @@ export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
): Promise<TempRepo> => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
|
||||
@@ -38,5 +38,7 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,21 @@ test('host removal removes the host from UI and persists after reload', async ({
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||
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) => {
|
||||
|
||||
@@ -41,10 +41,10 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
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();
|
||||
@@ -109,7 +109,6 @@ test('new agent auto-selects first online host when no preference is stored', as
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
|
||||
@@ -17,8 +17,28 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
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();
|
||||
@@ -27,11 +47,18 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
await expect(nameModal).toBeVisible({ timeout: 15000 });
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
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 }) => {
|
||||
|
||||
@@ -10,6 +10,15 @@ function encodeBase64Url(input: string): string {
|
||||
}
|
||||
|
||||
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 page.goto('/settings');
|
||||
await page.evaluate(() => {
|
||||
@@ -18,27 +27,39 @@ test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host',
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.reload();
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId: 'e2e-server-123',
|
||||
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
|
||||
relay: { endpoint: 'relay.local:443' },
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
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.getByText('Pair', { exact: true }).click();
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
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 }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
@@ -31,7 +32,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -71,7 +72,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -79,10 +80,8 @@ test.describe('permission prompts', () => {
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
|
||||
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
|
||||
@@ -13,10 +13,26 @@ test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
|
||||
// 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(/\/agent\/?$/);
|
||||
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -26,26 +26,13 @@ test("project filter dropdown never appears visibly at 0,0 on open", async ({ pa
|
||||
});
|
||||
};
|
||||
|
||||
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== document.body) {
|
||||
const style = getComputedStyle(current);
|
||||
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (!element.textContent?.includes("No projects")) continue;
|
||||
const container = getContainer(element);
|
||||
if (!container) continue;
|
||||
target = container;
|
||||
return true;
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
|
||||
function visibleTestIdPrefix(page: Page, prefix: string) {
|
||||
return page.locator(`[data-testid^="${prefix}"]:visible`);
|
||||
}
|
||||
|
||||
let terminalMarkerCounter = 0;
|
||||
|
||||
function shortTerminalMarker(prefix: string): string {
|
||||
terminalMarkerCounter += 1;
|
||||
return `${prefix.slice(0, 1)}${terminalMarkerCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
|
||||
const pathname = (() => {
|
||||
try {
|
||||
@@ -60,7 +75,7 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="working-directory-select"]:visible').first()
|
||||
).toBeVisible({
|
||||
@@ -68,38 +83,42 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
let header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
if (!(await header.isVisible().catch(() => false))) {
|
||||
const toggle = page.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
});
|
||||
if (await toggle.first().isVisible().catch(() => false)) {
|
||||
await toggle.first().click();
|
||||
}
|
||||
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
if (await filesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(header).toBeVisible({ timeout: 30000 });
|
||||
const toggle = page
|
||||
.getByRole("button", { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
await toggle.click();
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
|
||||
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
|
||||
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFilesPanel(page: Page): Promise<void> {
|
||||
const filesTab = page.getByTestId("explorer-tab-files").first();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
await filesTab.click();
|
||||
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -124,7 +143,7 @@ async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||
|
||||
|
||||
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
||||
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
|
||||
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
|
||||
await expect
|
||||
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||
@@ -133,7 +152,7 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
|
||||
const firstTab = visibleTestIdPrefix(page, "terminal-tab-").first();
|
||||
await expect(firstTab).toBeVisible({ timeout: 30000 });
|
||||
const value = await firstTab.getAttribute("data-testid");
|
||||
if (!value) {
|
||||
@@ -143,7 +162,7 @@ async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -158,7 +177,7 @@ async function runTerminalCommandWithPreEnterEcho(
|
||||
command: string,
|
||||
expectedText: string
|
||||
): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -218,11 +237,11 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
await createAgent(page, "Reply with exactly: terminal smoke");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
|
||||
const preEnterEchoMarker = shortTerminalMarker("typed");
|
||||
await runTerminalCommandWithPreEnterEcho(
|
||||
page,
|
||||
`echo ${preEnterEchoMarker}`,
|
||||
@@ -240,7 +259,7 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
const markerOne = `terminal-smoke-one-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
|
||||
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
const markerTwo = `terminal-smoke-two-${Date.now()}`;
|
||||
@@ -272,7 +291,7 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
|
||||
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
|
||||
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -295,7 +314,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -303,7 +322,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
const markerAfterEnters = `echo-visible-${Date.now()}`;
|
||||
const markerAfterEnters = shortTerminalMarker("visible");
|
||||
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
|
||||
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
|
||||
timeout: 30000,
|
||||
@@ -319,7 +338,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
|
||||
const markerAfterBackspace = shortTerminalMarker("backspace");
|
||||
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(markerAfterBackspace, {
|
||||
@@ -341,7 +360,7 @@ test("terminal remains interactive after alternate-screen enter/exit", async ({
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -375,7 +394,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
|
||||
const exitedTabTestId = await getFirstTerminalTabTestId(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type("exit", { delay: 1 });
|
||||
@@ -385,7 +404,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
const nextTabTestId = await getFirstTerminalTabTestId(page);
|
||||
@@ -408,7 +427,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-${Date.now()}`;
|
||||
@@ -425,7 +444,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
await dialog.dismiss();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
@@ -447,7 +466,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
|
||||
@@ -464,7 +483,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
await dialog.accept();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
|
||||
@@ -497,7 +516,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
await openAgentFromSidebar(page, second.serverId, second.agentId);
|
||||
@@ -510,7 +529,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await selectNewestTerminalTab(page);
|
||||
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
|
||||
await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
@@ -529,7 +548,7 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -546,7 +565,9 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
await page.keyboard.press("Control+B");
|
||||
await expect(surface).toContainText("^B", { timeout: 30000 });
|
||||
|
||||
const marker = `terminal-key-capture-${Date.now()}`;
|
||||
// Clear any line-editor residue before validating the next shell command.
|
||||
await page.keyboard.press("Enter");
|
||||
const marker = shortTerminalMarker("key-capture");
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, { timeout: 30000 });
|
||||
@@ -565,7 +586,7 @@ test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) =>
|
||||
await createAgent(page, "Terminal Cmd+B");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -594,17 +615,28 @@ async function getTerminalRows(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
|
||||
async function setTerminalHeightInset(page: Page, inset: number): Promise<void> {
|
||||
await page.evaluate((nextPadding) => {
|
||||
const container = document.querySelector<HTMLElement>(
|
||||
'[data-testid="explorer-content-area"]'
|
||||
const surfaces = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
|
||||
);
|
||||
if (!container) {
|
||||
const activeSurface =
|
||||
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
|
||||
if (!activeSurface) {
|
||||
return;
|
||||
}
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.paddingBottom = nextPadding + "px";
|
||||
}, padding);
|
||||
const host = activeSurface.parentElement as HTMLElement | null;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
if (nextPadding > 0) {
|
||||
host.style.flex = "0 0 auto";
|
||||
host.style.height = `calc(100% - ${nextPadding}px)`;
|
||||
return;
|
||||
}
|
||||
host.style.flex = "1 1 auto";
|
||||
host.style.height = "100%";
|
||||
}, inset);
|
||||
}
|
||||
|
||||
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
|
||||
@@ -646,12 +678,12 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
.toBeGreaterThan(0);
|
||||
const initialRows = await getTerminalRows(page);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 220);
|
||||
await setTerminalHeightInset(page, 220);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeLessThan(initialRows);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 0);
|
||||
await setTerminalHeightInset(page, 0);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(initialRows);
|
||||
@@ -679,7 +711,7 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
`${scrollbackMarker}-180`
|
||||
);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await surface.hover();
|
||||
await page.mouse.wheel(0, -3000);
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
// E2E tests share a single daemon/relay/metro stack from global setup.
|
||||
// Running tests concurrently causes cross-test contention and non-deterministic failures.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
|
||||
@@ -464,6 +464,7 @@ export default function RootLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
@@ -9,14 +9,26 @@ import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const requestedServerId = useMemo(() => {
|
||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
}, [params.serverId]);
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (requestedServerId) {
|
||||
const requested = daemons.find(
|
||||
(daemon) => daemon.serverId === requestedServerId
|
||||
);
|
||||
if (requested) {
|
||||
return requested.serverId;
|
||||
}
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
@@ -24,7 +36,7 @@ export default function Index() {
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
}, [daemons, preferences.serverId, requestedServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find(
|
||||
(daemon) => daemon.serverId === preferences.serverId
|
||||
);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -324,6 +324,7 @@ interface ComboSelectProps {
|
||||
onSelect: (id: string) => void;
|
||||
icon?: ReactElement;
|
||||
showLabel?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function ComboSelect({
|
||||
@@ -338,6 +339,7 @@ export function ComboSelect({
|
||||
onSelect,
|
||||
icon,
|
||||
showLabel = true,
|
||||
testID,
|
||||
}: ComboSelectProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -361,6 +363,7 @@ export function ComboSelect({
|
||||
controlRef={anchorRef}
|
||||
icon={icon}
|
||||
showLabel={showLabel}
|
||||
testID={testID}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
@@ -573,6 +576,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-provider-select"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -587,6 +591,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-model-select"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -600,6 +605,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
</View>
|
||||
{thinkingSelectOptions.length > 0 ? (
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
persistAttachmentFromFileUri,
|
||||
} from '@/attachments/service'
|
||||
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
||||
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -77,6 +78,7 @@ export function AgentInputArea({
|
||||
onAddImages,
|
||||
commandDraftConfig,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
const { theme } = useUnistyles()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
|
||||
|
||||
@@ -41,9 +41,10 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(strategy.kind).toBe("forward_stream");
|
||||
expect(strategy.flatListInverted).toBe(false);
|
||||
expect(strategy.overlayScrollbarInverted).toBe(false);
|
||||
expect(strategy.shouldUseVirtualizedList()).toBe(false);
|
||||
expect(strategy.getFlatListInverted()).toBe(false);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses inverted_stream on native", () => {
|
||||
@@ -52,9 +53,10 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(strategy.kind).toBe("inverted_stream");
|
||||
expect(strategy.flatListInverted).toBe(true);
|
||||
expect(strategy.overlayScrollbarInverted).toBe(true);
|
||||
expect(strategy.shouldUseVirtualizedList()).toBe(true);
|
||||
expect(strategy.getFlatListInverted()).toBe(true);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, ReactElement } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { ComponentType, ReactElement, RefObject } from "react";
|
||||
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
type EdgeSlot = "header" | "footer";
|
||||
@@ -11,44 +11,6 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||
autoscrollToTopThreshold: number;
|
||||
}>;
|
||||
|
||||
type StreamRenderStrategyBase = {
|
||||
kind: "inverted_stream" | "forward_stream";
|
||||
flatListInverted: boolean;
|
||||
edgeSlot: EdgeSlot;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||
};
|
||||
|
||||
export type InvertedStreamRenderStrategy = StreamRenderStrategyBase & {
|
||||
kind: "inverted_stream";
|
||||
flatListInverted: true;
|
||||
edgeSlot: "header";
|
||||
overlayScrollbarInverted: true;
|
||||
maintainVisibleContentPosition: MaintainVisibleContentPositionConfig;
|
||||
assistantTurnTraversalStep: 1;
|
||||
disableParentScrollOnInlineDetailsExpansion: false;
|
||||
};
|
||||
|
||||
export type ForwardStreamRenderStrategy = StreamRenderStrategyBase & {
|
||||
kind: "forward_stream";
|
||||
flatListInverted: false;
|
||||
edgeSlot: "footer";
|
||||
overlayScrollbarInverted: false;
|
||||
maintainVisibleContentPosition?: undefined;
|
||||
assistantTurnTraversalStep: -1;
|
||||
};
|
||||
|
||||
export type StreamRenderStrategy =
|
||||
| InvertedStreamRenderStrategy
|
||||
| ForwardStreamRenderStrategy;
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
platform: string;
|
||||
isMobileBreakpoint: boolean;
|
||||
};
|
||||
|
||||
export type StreamViewportMetrics = {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
@@ -66,65 +28,269 @@ export type StreamEdgeSlotProps = {
|
||||
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export type StreamRenderRefs = {
|
||||
flatListRef: RefObject<FlatList<StreamItem> | null>;
|
||||
scrollViewRef: RefObject<ScrollView | null>;
|
||||
bottomAnchorRef: RefObject<View | null>;
|
||||
};
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
platform: string;
|
||||
isMobileBreakpoint: boolean;
|
||||
};
|
||||
|
||||
export interface StreamRenderStrategy {
|
||||
orderTail: (streamItems: StreamItem[]) => StreamItem[];
|
||||
orderHead: (streamHead: StreamItem[]) => StreamItem[];
|
||||
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
|
||||
getNeighborItem: (
|
||||
items: StreamItem[],
|
||||
index: number,
|
||||
relation: NeighborRelation
|
||||
) => StreamItem | undefined;
|
||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
getEdgeSlotProps: (
|
||||
component: ReactElement | ComponentType<any> | null,
|
||||
gapSize: number
|
||||
) => StreamEdgeSlotProps;
|
||||
getMaintainVisibleContentPosition: () =>
|
||||
| MaintainVisibleContentPositionConfig
|
||||
| undefined;
|
||||
getFlatListInverted: () => boolean;
|
||||
getOverlayScrollbarInverted: () => boolean;
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||
shouldAnchorBottomOnContentSizeChange: () => boolean;
|
||||
shouldAnimateManualScrollToBottom: () => boolean;
|
||||
shouldUseVirtualizedList: () => boolean;
|
||||
scrollToBottom: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
metrics: StreamViewportMetrics;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
scrollToOffset: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
offset: number;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
type StreamRenderStrategyConfig = {
|
||||
orderTailReverse: boolean;
|
||||
orderHeadReverse: boolean;
|
||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||
edgeSlot: EdgeSlot;
|
||||
flatListInverted: boolean;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||
anchorBottomOnContentSizeChange: boolean;
|
||||
animateManualScrollToBottom: boolean;
|
||||
useVirtualizedList: boolean;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
scrollToBottom: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
metrics: StreamViewportMetrics;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
scrollToOffset: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
offset: number;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
|
||||
Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
|
||||
const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = {
|
||||
kind: "inverted_stream",
|
||||
flatListInverted: true,
|
||||
edgeSlot: "header",
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
assistantTurnTraversalStep: 1,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
};
|
||||
function scrollAnchorIntoView(params: {
|
||||
refs: StreamRenderRefs;
|
||||
animated: boolean;
|
||||
}): boolean {
|
||||
const anchorHandle = params.refs.bottomAnchorRef.current as
|
||||
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
|
||||
object)
|
||||
| null;
|
||||
if (!anchorHandle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = {
|
||||
kind: "forward_stream",
|
||||
flatListInverted: false,
|
||||
edgeSlot: "footer",
|
||||
overlayScrollbarInverted: false,
|
||||
assistantTurnTraversalStep: -1,
|
||||
disableParentScrollOnInlineDetailsExpansion: true,
|
||||
};
|
||||
const maybeNative =
|
||||
typeof anchorHandle.getNativeRef === "function"
|
||||
? anchorHandle.getNativeRef()
|
||||
: anchorHandle;
|
||||
|
||||
const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = {
|
||||
...FORWARD_STREAM_STRATEGY_DESKTOP,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
};
|
||||
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
|
||||
if (typeof domElement.scrollIntoView !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
domElement.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: params.animated ? "smooth" : "auto",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function createStreamRenderStrategy(
|
||||
config: StreamRenderStrategyConfig
|
||||
): StreamRenderStrategy {
|
||||
return {
|
||||
orderTail: (streamItems) =>
|
||||
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||
orderHead: (streamHead) =>
|
||||
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
||||
getNeighborIndex: (index, relation) =>
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep,
|
||||
getNeighborItem: (items, index, relation) => {
|
||||
const neighborIndex =
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep;
|
||||
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
||||
return undefined;
|
||||
}
|
||||
return items[neighborIndex];
|
||||
},
|
||||
collectAssistantTurnContent: (items, startIndex) => {
|
||||
const messages: string[] = [];
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += config.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
return messages.reverse().join("\n\n");
|
||||
},
|
||||
isNearBottom: (input) => config.isNearBottom(input),
|
||||
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
||||
getEdgeSlotProps: (component, gapSize) => {
|
||||
if (config.edgeSlot === "header") {
|
||||
return {
|
||||
ListHeaderComponent: component,
|
||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ListFooterComponent: component,
|
||||
ListFooterComponentStyle: { marginTop: gapSize },
|
||||
};
|
||||
},
|
||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||
getFlatListInverted: () => config.flatListInverted,
|
||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||
config.disableParentScrollOnInlineDetailsExpansion,
|
||||
shouldAnchorBottomOnContentSizeChange: () =>
|
||||
config.anchorBottomOnContentSizeChange,
|
||||
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||
scrollToBottom: (params) => config.scrollToBottom(params),
|
||||
scrollToOffset: (params) => config.scrollToOffset(params),
|
||||
};
|
||||
}
|
||||
|
||||
function createInvertedStreamStrategy(): StreamRenderStrategy {
|
||||
return createStreamRenderStrategy({
|
||||
orderTailReverse: true,
|
||||
orderHeadReverse: true,
|
||||
assistantTurnTraversalStep: 1,
|
||||
edgeSlot: "header",
|
||||
flatListInverted: true,
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: false,
|
||||
animateManualScrollToBottom: true,
|
||||
useVirtualizedList: true,
|
||||
isNearBottom: (input) => input.offsetY <= input.threshold,
|
||||
getBottomOffset: () => 0,
|
||||
scrollToBottom: ({ refs, animated }) => {
|
||||
refs.flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
},
|
||||
scrollToOffset: ({ refs, offset, animated }) => {
|
||||
refs.flatListRef.current?.scrollToOffset({ offset, animated });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||
return createStreamRenderStrategy({
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
assistantTurnTraversalStep: -1,
|
||||
edgeSlot: "footer",
|
||||
flatListInverted: false,
|
||||
overlayScrollbarInverted: false,
|
||||
maintainVisibleContentPosition: undefined,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: true,
|
||||
animateManualScrollToBottom: false,
|
||||
useVirtualizedList: false,
|
||||
isNearBottom: (inputMetrics) => {
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
inputMetrics.contentHeight -
|
||||
(inputMetrics.offsetY + inputMetrics.viewportHeight)
|
||||
);
|
||||
return distanceFromBottom <= inputMetrics.threshold;
|
||||
},
|
||||
getBottomOffset: (metrics) =>
|
||||
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
scrollToBottom: ({ refs, metrics, animated }) => {
|
||||
if (scrollAnchorIntoView({ refs, animated })) {
|
||||
return;
|
||||
}
|
||||
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
||||
refs.scrollViewRef.current?.scrollTo?.({
|
||||
y: Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
animated,
|
||||
});
|
||||
},
|
||||
scrollToOffset: ({ refs, offset, animated }) => {
|
||||
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveStreamRenderStrategy(
|
||||
input: ResolveStreamRenderStrategyInput
|
||||
): StreamRenderStrategy {
|
||||
if (input.platform === "web") {
|
||||
return input.isMobileBreakpoint
|
||||
? FORWARD_STREAM_STRATEGY_MOBILE
|
||||
: FORWARD_STREAM_STRATEGY_DESKTOP;
|
||||
return createForwardStreamStrategy();
|
||||
}
|
||||
return INVERTED_STREAM_STRATEGY;
|
||||
return createInvertedStreamStrategy();
|
||||
}
|
||||
|
||||
export function orderTailForStreamRenderStrategy(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
streamItems: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
const { strategy, streamItems } = params;
|
||||
return strategy.kind === "inverted_stream"
|
||||
? [...streamItems].reverse()
|
||||
: streamItems;
|
||||
return params.strategy.orderTail(params.streamItems);
|
||||
}
|
||||
|
||||
export function orderHeadForStreamRenderStrategy(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
streamHead: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
const { strategy, streamHead } = params;
|
||||
return strategy.kind === "inverted_stream"
|
||||
? [...streamHead].reverse()
|
||||
: streamHead;
|
||||
return params.strategy.orderHead(params.streamHead);
|
||||
}
|
||||
|
||||
export function getStreamNeighborIndex(params: {
|
||||
@@ -132,11 +298,7 @@ export function getStreamNeighborIndex(params: {
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): number {
|
||||
const { strategy, index, relation } = params;
|
||||
if (strategy.kind === "inverted_stream") {
|
||||
return relation === "above" ? index + 1 : index - 1;
|
||||
}
|
||||
return relation === "above" ? index - 1 : index + 1;
|
||||
return params.strategy.getNeighborIndex(params.index, params.relation);
|
||||
}
|
||||
|
||||
export function getStreamNeighborItem(params: {
|
||||
@@ -145,11 +307,11 @@ export function getStreamNeighborItem(params: {
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): StreamItem | undefined {
|
||||
const nextIndex = getStreamNeighborIndex(params);
|
||||
if (nextIndex < 0 || nextIndex >= params.items.length) {
|
||||
return undefined;
|
||||
}
|
||||
return params.items[nextIndex];
|
||||
return params.strategy.getNeighborItem(
|
||||
params.items,
|
||||
params.index,
|
||||
params.relation
|
||||
);
|
||||
}
|
||||
|
||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||
@@ -157,49 +319,32 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): string {
|
||||
const { strategy, items, startIndex } = params;
|
||||
const messages: string[] = [];
|
||||
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += strategy.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
|
||||
return messages.reverse().join("\n\n");
|
||||
return params.strategy.collectAssistantTurnContent(
|
||||
params.items,
|
||||
params.startIndex
|
||||
);
|
||||
}
|
||||
|
||||
export function isNearBottomForStreamRenderStrategy(
|
||||
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
||||
): boolean {
|
||||
const { strategy, threshold, offsetY } = params;
|
||||
if (strategy.kind === "inverted_stream") {
|
||||
return offsetY <= threshold;
|
||||
}
|
||||
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
params.contentHeight - (offsetY + params.viewportHeight)
|
||||
);
|
||||
return distanceFromBottom <= threshold;
|
||||
return params.strategy.isNearBottom({
|
||||
offsetY: params.offsetY,
|
||||
threshold: params.threshold,
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & {
|
||||
strategy: StreamRenderStrategy;
|
||||
}): number {
|
||||
const { strategy } = params;
|
||||
if (strategy.kind === "inverted_stream") {
|
||||
return 0;
|
||||
export function getBottomOffsetForStreamRenderStrategy(
|
||||
params: StreamViewportMetrics & {
|
||||
strategy: StreamRenderStrategy;
|
||||
}
|
||||
return Math.max(0, params.contentHeight - params.viewportHeight);
|
||||
): number {
|
||||
return params.strategy.getBottomOffset({
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreamEdgeSlotProps(params: {
|
||||
@@ -207,15 +352,5 @@ export function getStreamEdgeSlotProps(params: {
|
||||
component: ReactElement | ComponentType<any> | null;
|
||||
gapSize: number;
|
||||
}): StreamEdgeSlotProps {
|
||||
const { strategy, component, gapSize } = params;
|
||||
if (strategy.edgeSlot === "header") {
|
||||
return {
|
||||
ListHeaderComponent: component,
|
||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ListFooterComponent: component,
|
||||
ListFooterComponentStyle: { marginTop: gapSize },
|
||||
};
|
||||
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
createElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
ListRenderItemInfo,
|
||||
LayoutChangeEvent,
|
||||
NativeScrollEvent,
|
||||
@@ -57,13 +67,13 @@ import {
|
||||
} from "./web-desktop-scrollbar";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getBottomOffsetForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborItem,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
type StreamEdgeSlotProps,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
@@ -76,6 +86,24 @@ const isToolSequenceItem = (item?: StreamItem) =>
|
||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
|
||||
highlight: () => {},
|
||||
unhighlight: () => {},
|
||||
updateProps: () => {},
|
||||
};
|
||||
|
||||
function renderStreamEdgeComponent(
|
||||
component: ReactElement | ComponentType<any> | null | undefined
|
||||
): ReactNode {
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
if (isValidElement(component)) {
|
||||
return component;
|
||||
}
|
||||
return createElement(component);
|
||||
}
|
||||
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
@@ -92,6 +120,8 @@ export function AgentStreamView({
|
||||
pendingPermissions,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const bottomAnchorRef = useRef<View>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -111,6 +141,7 @@ export function AgentStreamView({
|
||||
const isNearBottomRef = useRef(true);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const streamViewportMetricsRef = useRef({
|
||||
contentHeight: 0,
|
||||
@@ -120,6 +151,10 @@ export function AgentStreamView({
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const streamRenderRefs = useMemo(
|
||||
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
|
||||
[]
|
||||
);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -192,6 +227,7 @@ export function AgentStreamView({
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||
@@ -235,36 +271,61 @@ export function AgentStreamView({
|
||||
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
||||
streamViewportMetricsRef.current
|
||||
);
|
||||
streamRenderStrategy.scrollToBottom({
|
||||
refs: streamRenderRefs,
|
||||
metrics: streamViewportMetricsRef.current,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = targetOffset;
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
},
|
||||
[streamRenderRefs, streamRenderStrategy]
|
||||
);
|
||||
|
||||
const handleContentSizeChange = useCallback(
|
||||
(width: number, height: number) => {
|
||||
const previousMetrics = streamViewportMetricsRef.current;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
const wasNearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
threshold,
|
||||
contentHeight: previousMetrics.contentHeight,
|
||||
viewportHeight: previousMetrics.viewportHeight,
|
||||
});
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
...previousMetrics,
|
||||
contentHeight: Math.max(0, height),
|
||||
};
|
||||
|
||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
} else if (wasNearBottom || isNearBottomRef.current) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onContentSizeChange(width, height);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const list = flatListRef.current;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = getBottomOffsetForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
list.scrollToOffset({ offset, animated });
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
},
|
||||
[streamRenderStrategy]
|
||||
[
|
||||
insets.bottom,
|
||||
scrollToBottomInternal,
|
||||
showDesktopWebScrollbar,
|
||||
streamRenderStrategy,
|
||||
streamScrollbarMetrics,
|
||||
]
|
||||
);
|
||||
|
||||
const scheduleAutoScroll = useCallback(
|
||||
@@ -301,6 +362,11 @@ export function AgentStreamView({
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
// Forward streams anchor from measurement updates in handleContentSizeChange.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
@@ -317,10 +383,16 @@ export function AgentStreamView({
|
||||
const shouldAnimate = hasScrolledInitially.current;
|
||||
scheduleAutoScroll({ animated: shouldAnimate });
|
||||
hasScrolledInitially.current = true;
|
||||
}, [streamItems, scheduleAutoScroll]);
|
||||
}, [
|
||||
scheduleAutoScroll,
|
||||
scrollToBottomInternal,
|
||||
streamItems,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
|
||||
function scrollToBottom() {
|
||||
scrollToBottomInternal({ animated: true });
|
||||
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
|
||||
scrollToBottomInternal({ animated });
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
}
|
||||
@@ -396,7 +468,7 @@ export function AgentStreamView({
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
||||
if (
|
||||
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -660,6 +732,7 @@ export function AgentStreamView({
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
|
||||
|
||||
const listEdgeSlotComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
@@ -718,8 +791,8 @@ export function AgentStreamView({
|
||||
client,
|
||||
orderedStreamHead,
|
||||
renderStreamItemContent,
|
||||
tightGap,
|
||||
showBottomBar,
|
||||
tightGap,
|
||||
]);
|
||||
|
||||
const flatListExtraData = useMemo(
|
||||
@@ -735,7 +808,7 @@ export function AgentStreamView({
|
||||
]
|
||||
);
|
||||
|
||||
const listEdgeSlotProps = useMemo(() => {
|
||||
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
|
||||
if (!listEdgeSlotComponent) {
|
||||
return {};
|
||||
}
|
||||
@@ -781,10 +854,45 @@ export function AgentStreamView({
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const listContentContainerStyle = useMemo(
|
||||
() =>
|
||||
usesVirtualizedList
|
||||
? stylesheet.listContentContainer
|
||||
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
|
||||
[usesVirtualizedList]
|
||||
);
|
||||
const headerEdgeContent = renderStreamEdgeComponent(
|
||||
listEdgeSlotProps.ListHeaderComponent
|
||||
);
|
||||
const footerEdgeContent = renderStreamEdgeComponent(
|
||||
listEdgeSlotProps.ListFooterComponent
|
||||
);
|
||||
const nonVirtualizedItems = useMemo(() => {
|
||||
if (flatListData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return flatListData.map((item, index) => {
|
||||
const rendered = renderStreamItem({
|
||||
item,
|
||||
index,
|
||||
separators: NOOP_SEPARATORS,
|
||||
});
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return <Fragment key={item.id}>{rendered}</Fragment>;
|
||||
});
|
||||
}, [flatListData, renderStreamItem]);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{usesVirtualizedList ? (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={flatListData}
|
||||
@@ -792,7 +900,7 @@ export function AgentStreamView({
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
{...listEdgeSlotProps}
|
||||
contentContainerStyle={stylesheet.listContentContainer}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
@@ -801,51 +909,77 @@ export function AgentStreamView({
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={
|
||||
streamRenderStrategy.maintainVisibleContentPosition
|
||||
streamRenderStrategy.getMaintainVisibleContentPosition()
|
||||
}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={
|
||||
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion ||
|
||||
expandedInlineToolCallIds.size === 0
|
||||
}
|
||||
scrollEnabled={streamScrollEnabled}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted={streamRenderStrategy.flatListInverted}
|
||||
inverted={streamRenderStrategy.getFlatListInverted()}
|
||||
/>
|
||||
</MessageOuterSpacingProvider>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted={streamRenderStrategy.overlayScrollbarInverted}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
) : (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID="agent-chat-scroll"
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
scrollEnabled={streamScrollEnabled}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{headerEdgeContent ? (
|
||||
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
||||
{headerEdgeContent}
|
||||
</View>
|
||||
) : null}
|
||||
{nonVirtualizedItems}
|
||||
{flatListData.length === 0 ? listEmptyComponent : null}
|
||||
{footerEdgeContent ? (
|
||||
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
||||
{footerEdgeContent}
|
||||
</View>
|
||||
) : null}
|
||||
<View ref={bottomAnchorRef} collapsable={false} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</MessageOuterSpacingProvider>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
streamRenderStrategy.scrollToOffset({
|
||||
refs: streamRenderRefs,
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ToolCallSheetProvider>
|
||||
);
|
||||
}
|
||||
@@ -1380,6 +1514,10 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
},
|
||||
forwardListContentContainer: {
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -162,6 +162,8 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel dictation"
|
||||
style={[
|
||||
overlayStyles.cancelButton,
|
||||
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
|
||||
@@ -213,6 +215,8 @@ export function DictationOverlay({
|
||||
) : isFailed ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry dictation"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
@@ -228,6 +232,8 @@ export function DictationOverlay({
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
|
||||
@@ -241,6 +247,8 @@ export function DictationOverlay({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -52,6 +53,7 @@ interface ExplorerSidebarProps {
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -267,6 +269,12 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
|
||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
if (!isScreenFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
|
||||
@@ -33,6 +33,10 @@ import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-ur
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
|
||||
import {
|
||||
markScrollInvestigationEvent,
|
||||
markScrollInvestigationRender,
|
||||
} from '@/utils/scroll-jank-investigation'
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata
|
||||
|
||||
@@ -148,6 +152,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles()
|
||||
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
|
||||
markScrollInvestigationRender(investigationComponentId)
|
||||
const toast = useToast()
|
||||
const voice = useVoiceOptional()
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
|
||||
@@ -247,7 +253,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
if (shouldAutoSend) {
|
||||
const imageAttachments = images.length > 0 ? images : undefined
|
||||
onSubmit({ text: nextValue, images: imageAttachments })
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
})
|
||||
} else {
|
||||
onChangeText(nextValue)
|
||||
}
|
||||
@@ -258,7 +268,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
})
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, images]
|
||||
[onChangeText, onSubmit, images, isAgentRunning]
|
||||
)
|
||||
|
||||
const handleDictationError = useCallback((error: Error) => {
|
||||
@@ -302,8 +312,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden:
|
||||
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
|
||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
||||
enableDuration: true,
|
||||
})
|
||||
|
||||
@@ -330,9 +339,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
const startDictationIfAvailable = useCallback(async () => {
|
||||
if (dictationUnavailableMessage) {
|
||||
isDictatingRef.current = false
|
||||
toast.error(dictationUnavailableMessage)
|
||||
return
|
||||
}
|
||||
// Keep hotkey toggling deterministic between the async start call and the
|
||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||
isDictatingRef.current = true
|
||||
await startDictation()
|
||||
}, [dictationUnavailableMessage, startDictation, toast])
|
||||
|
||||
@@ -590,6 +603,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const shouldHandleDesktopSubmit = IS_WEB
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
|
||||
if (!shouldHandleDesktopSubmit) return
|
||||
|
||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||
@@ -634,6 +648,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
? 'Send and interrupt'
|
||||
: 'Send message'
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
|
||||
onChangeText(nextValue)
|
||||
},
|
||||
[investigationComponentId, onChangeText]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="message-input-root">
|
||||
{/* Regular input */}
|
||||
@@ -672,7 +694,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.mutedForeground}
|
||||
onFocus={() => {
|
||||
|
||||
@@ -72,6 +72,7 @@ import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -1293,6 +1294,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const detailContent =
|
||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||
const detailWrapperRef = useRef<View | null>(null);
|
||||
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
|
||||
testID ?? label
|
||||
}`;
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||
@@ -1428,11 +1432,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
};
|
||||
|
||||
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach");
|
||||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||
return () => {
|
||||
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
|
||||
node.removeEventListener("wheel", stopWheelPropagation);
|
||||
};
|
||||
}, [isExpanded, hasDetailContent]);
|
||||
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
|
||||
@@ -17,6 +17,7 @@ import Svg, {
|
||||
} from "react-native-svg";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
@@ -557,29 +558,60 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dispatchTerminalInputEntry = useCallback(
|
||||
(entry: PendingTerminalInput): boolean => {
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const terminalId = selectedTerminalIdRef.current;
|
||||
if (!terminalId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.type === "data") {
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: entry.data,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const encoded = encodeTerminalKeyInput(entry.input);
|
||||
if (encoded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: encoded,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const flushPendingTerminalInput = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
if (currentStreamId === null) {
|
||||
return;
|
||||
}
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
if (queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = queue.splice(0, queue.length);
|
||||
|
||||
for (const entry of pending) {
|
||||
if (entry.type === "data") {
|
||||
client.sendTerminalStreamInput(currentStreamId, entry.data);
|
||||
continue;
|
||||
let sentCount = 0;
|
||||
while (sentCount < queue.length) {
|
||||
const entry = queue[sentCount];
|
||||
if (!entry) {
|
||||
break;
|
||||
}
|
||||
client.sendTerminalStreamKey(currentStreamId, entry.input);
|
||||
if (!dispatchTerminalInputEntry(entry)) {
|
||||
break;
|
||||
}
|
||||
sentCount += 1;
|
||||
}
|
||||
}, [client, getCurrentActiveStreamId]);
|
||||
|
||||
if (sentCount > 0) {
|
||||
queue.splice(0, sentCount);
|
||||
}
|
||||
}, [dispatchTerminalInputEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
flushPendingTerminalInput();
|
||||
@@ -625,8 +657,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
meta?: boolean;
|
||||
}
|
||||
): boolean => {
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
if (!client || currentStreamId === null) {
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
input: {
|
||||
@@ -641,6 +672,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
||||
const pendingEntry: PendingTerminalInput = {
|
||||
type: "key",
|
||||
input: {
|
||||
key: normalizedKey,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
},
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:key:send",
|
||||
@@ -649,19 +690,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
activeStreamId: currentStreamId,
|
||||
activeStreamId: getCurrentActiveStreamId(),
|
||||
},
|
||||
});
|
||||
client.sendTerminalStreamKey(currentStreamId, {
|
||||
key: normalizedKey,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
});
|
||||
if (!dispatchTerminalInputEntry(pendingEntry)) {
|
||||
enqueuePendingTerminalInput(pendingEntry);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
|
||||
[
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
enqueuePendingTerminalInput,
|
||||
getCurrentActiveStreamId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -705,7 +747,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!client || currentStreamId === null) {
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "data",
|
||||
data,
|
||||
@@ -721,11 +763,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
client.sendTerminalStreamInput(currentStreamId, data);
|
||||
const pendingEntry: PendingTerminalInput = {
|
||||
type: "data",
|
||||
data,
|
||||
};
|
||||
if (!dispatchTerminalInputEntry(pendingEntry)) {
|
||||
enqueuePendingTerminalInput(pendingEntry);
|
||||
}
|
||||
},
|
||||
[
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
getCurrentActiveStreamId,
|
||||
modifiers.alt,
|
||||
modifiers.ctrl,
|
||||
|
||||
@@ -3,18 +3,45 @@ import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(__private__.buildInitialTimelineRequest(undefined)).toEqual({
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor when present", () => {
|
||||
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
},
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor when local tail exists", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
},
|
||||
hasLocalTail: true,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
@@ -23,4 +50,18 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports unbounded tail bootstrap policy", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 0,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type {
|
||||
DaemonClient,
|
||||
@@ -13,20 +14,28 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||
|
||||
type TimelineCursorState = {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
type BuildInitialTimelineRequestInput = {
|
||||
cursor: TimelineCursorState | undefined;
|
||||
hasLocalTail: boolean;
|
||||
initialTimelineLimit: number;
|
||||
};
|
||||
|
||||
function buildInitialTimelineRequest(
|
||||
cursor: TimelineCursorState | undefined
|
||||
input: BuildInitialTimelineRequestInput
|
||||
): FetchAgentTimelineOptions {
|
||||
if (!cursor) {
|
||||
const { cursor, hasLocalTail, initialTimelineLimit } = input;
|
||||
if (!cursor || !hasLocalTail) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
@@ -40,8 +49,15 @@ function buildInitialTimelineRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInitialTimelineLimit(): number {
|
||||
return Platform.OS === "web"
|
||||
? UNBOUNDED_TIMELINE_LIMIT
|
||||
: NATIVE_INITIAL_TIMELINE_LIMIT;
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
buildInitialTimelineRequest,
|
||||
resolveInitialTimelineLimit,
|
||||
};
|
||||
|
||||
export function useAgentInitialization({
|
||||
@@ -76,7 +92,13 @@ export function useAgentInitialization({
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const timelineRequest = buildInitialTimelineRequest(cursor);
|
||||
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const timelineRequest = buildInitialTimelineRequest({
|
||||
cursor,
|
||||
hasLocalTail,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
const initRequestDirection =
|
||||
timelineRequest.direction === "after" ? "after" : "tail";
|
||||
|
||||
@@ -128,9 +150,10 @@ export function useAgentInitialization({
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -57,6 +57,92 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
return out;
|
||||
};
|
||||
|
||||
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i]! / 32768;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
type Pcm16Wav = {
|
||||
sampleRate: number;
|
||||
samples: Int16Array;
|
||||
};
|
||||
|
||||
const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => {
|
||||
if (buffer.byteLength < 44) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const readAscii = (offset: number, length: number): string => {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
out += String.fromCharCode(view.getUint8(offset + i));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let channels = 0;
|
||||
let sampleRate = 0;
|
||||
let bitsPerSample = 0;
|
||||
let dataOffset = 0;
|
||||
let dataSize = 0;
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const chunkId = readAscii(offset, 4);
|
||||
const chunkSize = view.getUint32(offset + 4, true);
|
||||
const chunkDataOffset = offset + 8;
|
||||
if (chunkDataOffset + chunkSize > buffer.byteLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunkId === "fmt " && chunkSize >= 16) {
|
||||
const audioFormat = view.getUint16(chunkDataOffset, true);
|
||||
channels = view.getUint16(chunkDataOffset + 2, true);
|
||||
sampleRate = view.getUint32(chunkDataOffset + 4, true);
|
||||
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
|
||||
if (audioFormat !== 1) {
|
||||
return null;
|
||||
}
|
||||
} else if (chunkId === "data") {
|
||||
dataOffset = chunkDataOffset;
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
|
||||
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleCount = Math.floor(dataSize / 2);
|
||||
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
|
||||
|
||||
if (channels === 1) {
|
||||
return { sampleRate, samples: new Int16Array(interleaved) };
|
||||
}
|
||||
|
||||
const frameCount = Math.floor(interleaved.length / channels);
|
||||
const mono = new Int16Array(frameCount);
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
let sum = 0;
|
||||
for (let ch = 0; ch < channels; ch += 1) {
|
||||
sum += interleaved[frame * channels + ch] ?? 0;
|
||||
}
|
||||
mono[frame] = Math.round(sum / channels);
|
||||
}
|
||||
return { sampleRate, samples: mono };
|
||||
};
|
||||
|
||||
const int16ToBase64 = (pcm: Int16Array): string => {
|
||||
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
let binary = "";
|
||||
@@ -359,15 +445,22 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "recorder" && recorder.stoppedPromise && context) {
|
||||
if (mode === "recorder" && recorder.stoppedPromise) {
|
||||
try {
|
||||
const blob = await recorder.stoppedPromise;
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > 0) {
|
||||
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||
const floatPcm = decoded.getChannelData(0);
|
||||
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
const parsedWav = parsePcm16Wav(arrayBuffer);
|
||||
if (parsedWav) {
|
||||
const floatPcm = int16ToFloat32(parsedWav.samples);
|
||||
const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
} else if (context) {
|
||||
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||
const floatPcm = decoded.getChannelData(0);
|
||||
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
@@ -32,6 +32,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const latestPartialTranscriptRef = useRef("");
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
useEffect(() => {
|
||||
@@ -130,6 +131,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const clearStreamingState = useCallback(() => {
|
||||
senderRef.current?.clearAll();
|
||||
latestPartialTranscriptRef.current = "";
|
||||
setPartialTranscript("");
|
||||
}, []);
|
||||
|
||||
@@ -181,6 +183,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
return;
|
||||
}
|
||||
const next = message.payload.text ?? "";
|
||||
latestPartialTranscriptRef.current = next;
|
||||
setPartialTranscript(next);
|
||||
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
|
||||
});
|
||||
@@ -203,13 +206,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
(text: string, requestId: string) => {
|
||||
setIsProcessing(false);
|
||||
isProcessingRef.current = false;
|
||||
setPartialTranscript("");
|
||||
setDuration(0);
|
||||
setStatus("idle");
|
||||
|
||||
const transcriptText = (
|
||||
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
|
||||
);
|
||||
clearStreamingState();
|
||||
|
||||
const transcriptText = text.trim();
|
||||
if (!transcriptText) {
|
||||
return;
|
||||
}
|
||||
@@ -261,14 +265,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
try {
|
||||
await audio.start();
|
||||
if (client?.isConnected) {
|
||||
await startNewStream("start");
|
||||
}
|
||||
isRecordingRef.current = true;
|
||||
setIsRecording(true);
|
||||
if (enableDuration) {
|
||||
startDurationTracking();
|
||||
}
|
||||
if (client?.isConnected) {
|
||||
await startNewStream("start");
|
||||
}
|
||||
} catch (err) {
|
||||
await audio.stop().catch(() => undefined);
|
||||
stopDurationTracking();
|
||||
|
||||
@@ -745,6 +745,7 @@ describe("HostRuntimeStore", () => {
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
filter: { labels: { ui: "true" } },
|
||||
subscribe: { subscriptionId: "app:srv_test" },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot(host.serverId);
|
||||
|
||||
@@ -1088,8 +1088,9 @@ function AgentScreenContent({
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
|
||||
<View style={styles.menuMetaContainer}>
|
||||
<View style={styles.menuMetaContainer} testID="agent-details-sheet">
|
||||
<Pressable
|
||||
testID="agent-details-agent-id"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.menuMetaRow,
|
||||
(hovered || pressed) && styles.menuMetaRowActive,
|
||||
@@ -1102,6 +1103,7 @@ function AgentScreenContent({
|
||||
Directory
|
||||
</Text>
|
||||
<Text
|
||||
testID="agent-details-agent-id-value"
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
@@ -1179,6 +1181,7 @@ function AgentScreenContent({
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="agent-details-persistence-session-id"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.menuMetaRow,
|
||||
providerSessionId && (hovered || pressed) && styles.menuMetaRowActive,
|
||||
@@ -1192,6 +1195,7 @@ function AgentScreenContent({
|
||||
{providerLabel} ID
|
||||
</Text>
|
||||
<Text
|
||||
testID="agent-details-persistence-session-id-value"
|
||||
style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
|
||||
@@ -1209,6 +1209,7 @@ function DraftAgentScreenContent({
|
||||
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
valueEllipsizeMode="middle"
|
||||
testID="worktree-select-trigger"
|
||||
/>
|
||||
{worktreeMode === 'create' ? (
|
||||
<FormSelectTrigger
|
||||
@@ -1221,6 +1222,7 @@ function DraftAgentScreenContent({
|
||||
disabled={repoInfoStatus === 'loading'}
|
||||
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="worktree-base-branch-trigger"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface KeyboardShortcutsState {
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutAgentKeys: string[];
|
||||
messageInputActionRequest: MessageInputActionRequest | null;
|
||||
nextMessageInputActionRequestId: number;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
@@ -38,6 +39,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
messageInputActionRequest: null,
|
||||
nextMessageInputActionRequestId: 1,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
@@ -47,9 +49,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
|
||||
requestMessageInputAction: ({ agentKey, kind }) => {
|
||||
const previous = get().messageInputActionRequest;
|
||||
const id = (previous?.id ?? 0) + 1;
|
||||
set({ messageInputActionRequest: { id, agentKey, kind } });
|
||||
const id = get().nextMessageInputActionRequestId;
|
||||
set({
|
||||
messageInputActionRequest: { id, agentKey, kind },
|
||||
nextMessageInputActionRequestId: id + 1,
|
||||
});
|
||||
},
|
||||
clearMessageInputActionRequest: (id) => {
|
||||
const current = get().messageInputActionRequest;
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
type ListenerStats = {
|
||||
adds: number;
|
||||
removes: number;
|
||||
active: number;
|
||||
};
|
||||
|
||||
type TimerStats = {
|
||||
created: number;
|
||||
fired: number;
|
||||
cleared: number;
|
||||
active: number;
|
||||
};
|
||||
|
||||
type WebSocketStats = {
|
||||
created: number;
|
||||
opened: number;
|
||||
closed: number;
|
||||
errored: number;
|
||||
active: number;
|
||||
};
|
||||
|
||||
type ComponentStats = {
|
||||
mounts: number;
|
||||
unmounts: number;
|
||||
renders: number;
|
||||
scrollEvents: number;
|
||||
nearBottomTransitions: number;
|
||||
metricUpdates: number;
|
||||
itemRenderCalls: number;
|
||||
wheelAttach: number;
|
||||
wheelDetach: number;
|
||||
inputChanges: number;
|
||||
keyPresses: number;
|
||||
lastRenderAtMs: number;
|
||||
};
|
||||
|
||||
type ScrollInvestigationStore = {
|
||||
markRender: (componentId: string) => void;
|
||||
markEvent: (
|
||||
componentId: string,
|
||||
event:
|
||||
| "mount"
|
||||
| "unmount"
|
||||
| "scrollEvent"
|
||||
| "nearBottomTransition"
|
||||
| "metricUpdate"
|
||||
| "itemRenderCall"
|
||||
| "wheelAttach"
|
||||
| "wheelDetach"
|
||||
| "inputChange"
|
||||
| "keyPress"
|
||||
) => void;
|
||||
snapshot: () => {
|
||||
listeners: {
|
||||
byType: Record<string, ListenerStats>;
|
||||
byCallsite: Record<string, number>;
|
||||
activeUniqueKeys: number;
|
||||
activeByTypeAndTarget: Record<string, Record<string, number>>;
|
||||
};
|
||||
timers: {
|
||||
timeout: TimerStats;
|
||||
interval: TimerStats;
|
||||
raf: TimerStats;
|
||||
};
|
||||
websockets: {
|
||||
totals: WebSocketStats;
|
||||
activeByUrl: Record<string, number>;
|
||||
};
|
||||
components: Record<string, ComponentStats>;
|
||||
};
|
||||
printSnapshot: (label?: string) => void;
|
||||
_installedAtMs: number;
|
||||
};
|
||||
|
||||
type ScrollInvestigationGlobal = typeof globalThis & {
|
||||
__PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
|
||||
__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
|
||||
};
|
||||
|
||||
const TRACKED_EVENT_TYPES = new Set([
|
||||
"wheel",
|
||||
"scroll",
|
||||
"pointermove",
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
]);
|
||||
|
||||
const SOURCE_LABEL = "[ScrollJankInvestigation]";
|
||||
|
||||
function shouldInstall(): boolean {
|
||||
const runtime = globalThis as ScrollInvestigationGlobal;
|
||||
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
return (
|
||||
Platform.OS === "web" &&
|
||||
isDev &&
|
||||
!runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
|
||||
if (typeof options === "boolean") {
|
||||
return options;
|
||||
}
|
||||
return Boolean(options?.capture);
|
||||
}
|
||||
|
||||
function describeEventTarget(target: EventTarget): string {
|
||||
const element = target as Element;
|
||||
if (element && typeof element === "object" && "tagName" in element) {
|
||||
const tagName = (element.tagName || "unknown").toLowerCase();
|
||||
const testId = element.getAttribute?.("data-testid");
|
||||
const role = element.getAttribute?.("role");
|
||||
const id = (element as HTMLElement).id || null;
|
||||
const className = (element as HTMLElement).className;
|
||||
const classLabel =
|
||||
typeof className === "string" && className.trim().length > 0
|
||||
? className.trim().split(/\s+/).slice(0, 2).join(".")
|
||||
: null;
|
||||
const connectivityLabel =
|
||||
typeof (element as Node).isConnected === "boolean"
|
||||
? (element as Node).isConnected
|
||||
? "[connected]"
|
||||
: "[detached]"
|
||||
: null;
|
||||
|
||||
return [
|
||||
tagName,
|
||||
id ? `#${id}` : null,
|
||||
testId ? `[data-testid=${testId}]` : null,
|
||||
role ? `[role=${role}]` : null,
|
||||
classLabel ? `.${classLabel}` : null,
|
||||
connectivityLabel,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const ctorName = (target as { constructor?: { name?: string } }).constructor
|
||||
?.name;
|
||||
return ctorName || "unknown-target";
|
||||
}
|
||||
|
||||
function inferCallsite(): string {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) {
|
||||
return "unknown";
|
||||
}
|
||||
const frames = stack.split("\n");
|
||||
for (const raw of frames.slice(2)) {
|
||||
const line = raw.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.includes("scroll-jank-investigation")) {
|
||||
continue;
|
||||
}
|
||||
if (line.includes("patchedAddEventListener")) {
|
||||
continue;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function ensureListenerStats(
|
||||
map: Map<string, ListenerStats>,
|
||||
type: string
|
||||
): ListenerStats {
|
||||
const existing = map.get(type);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
|
||||
map.set(type, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureComponentStats(
|
||||
map: Map<string, ComponentStats>,
|
||||
componentId: string
|
||||
): ComponentStats {
|
||||
const existing = map.get(componentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next: ComponentStats = {
|
||||
mounts: 0,
|
||||
unmounts: 0,
|
||||
renders: 0,
|
||||
scrollEvents: 0,
|
||||
nearBottomTransitions: 0,
|
||||
metricUpdates: 0,
|
||||
itemRenderCalls: 0,
|
||||
wheelAttach: 0,
|
||||
wheelDetach: 0,
|
||||
inputChanges: 0,
|
||||
keyPresses: 0,
|
||||
lastRenderAtMs: 0,
|
||||
};
|
||||
map.set(componentId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function installScrollJankInvestigation(): void {
|
||||
if (!shouldInstall()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = globalThis as ScrollInvestigationGlobal;
|
||||
if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIds = new WeakMap<object, number>();
|
||||
const listenerIds = new WeakMap<object, number>();
|
||||
const activeListenerKeys = new Set<string>();
|
||||
const activeListenerMeta = new Map<
|
||||
string,
|
||||
{ type: string; target: string }
|
||||
>();
|
||||
const listenerStatsByType = new Map<string, ListenerStats>();
|
||||
const listenerCallsiteCount = new Map<string, number>();
|
||||
const componentStatsById = new Map<string, ComponentStats>();
|
||||
const activeWsByUrl = new Map<string, number>();
|
||||
const timeoutHandles = new Map<number, true>();
|
||||
const intervalHandles = new Map<number, true>();
|
||||
const rafHandles = new Map<number, true>();
|
||||
let nextTargetId = 1;
|
||||
let nextListenerId = 1;
|
||||
|
||||
const timerStats = {
|
||||
timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
||||
interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
||||
raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
||||
};
|
||||
const websocketStats: WebSocketStats = {
|
||||
created: 0,
|
||||
opened: 0,
|
||||
closed: 0,
|
||||
errored: 0,
|
||||
active: 0,
|
||||
};
|
||||
|
||||
const eventTargetProto = EventTarget.prototype as EventTarget & {
|
||||
addEventListener: EventTarget["addEventListener"];
|
||||
removeEventListener: EventTarget["removeEventListener"];
|
||||
};
|
||||
const nativeAddEventListener = eventTargetProto.addEventListener;
|
||||
const nativeRemoveEventListener = eventTargetProto.removeEventListener;
|
||||
|
||||
function getTargetId(target: EventTarget): string {
|
||||
const targetObj = target as unknown as object;
|
||||
const existing = targetIds.get(targetObj);
|
||||
if (existing) {
|
||||
return String(existing);
|
||||
}
|
||||
const next = nextTargetId++;
|
||||
targetIds.set(targetObj, next);
|
||||
return String(next);
|
||||
}
|
||||
|
||||
function getListenerId(listener: EventListenerOrEventListenerObject): string {
|
||||
const listenerObj = listener as unknown as object;
|
||||
const existing = listenerIds.get(listenerObj);
|
||||
if (existing) {
|
||||
return String(existing);
|
||||
}
|
||||
const next = nextListenerId++;
|
||||
listenerIds.set(listenerObj, next);
|
||||
return String(next);
|
||||
}
|
||||
|
||||
function toListenerKey(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
): string {
|
||||
return [
|
||||
getTargetId(target),
|
||||
type,
|
||||
normalizeCapture(options) ? "capture" : "bubble",
|
||||
getListenerId(listener),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
eventTargetProto.addEventListener = function patchedAddEventListener(
|
||||
this: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
): void {
|
||||
nativeAddEventListener.call(this, type, listener as any, options as any);
|
||||
if (!listener) {
|
||||
return;
|
||||
}
|
||||
const stats = ensureListenerStats(listenerStatsByType, type);
|
||||
stats.adds += 1;
|
||||
|
||||
const key = toListenerKey(this, type, listener, options);
|
||||
if (!activeListenerKeys.has(key)) {
|
||||
activeListenerKeys.add(key);
|
||||
stats.active += 1;
|
||||
activeListenerMeta.set(key, {
|
||||
type,
|
||||
target: describeEventTarget(this),
|
||||
});
|
||||
}
|
||||
|
||||
if (TRACKED_EVENT_TYPES.has(type)) {
|
||||
const callsite = inferCallsite();
|
||||
const metricKey = `${type} :: ${callsite}`;
|
||||
listenerCallsiteCount.set(
|
||||
metricKey,
|
||||
(listenerCallsiteCount.get(metricKey) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
eventTargetProto.removeEventListener = function patchedRemoveEventListener(
|
||||
this: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: EventListenerOptions | boolean
|
||||
): void {
|
||||
nativeRemoveEventListener.call(this, type, listener as any, options as any);
|
||||
if (!listener) {
|
||||
return;
|
||||
}
|
||||
const stats = ensureListenerStats(listenerStatsByType, type);
|
||||
stats.removes += 1;
|
||||
|
||||
const key = toListenerKey(this, type, listener, options);
|
||||
if (activeListenerKeys.delete(key)) {
|
||||
stats.active = Math.max(0, stats.active - 1);
|
||||
activeListenerMeta.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
|
||||
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
|
||||
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
|
||||
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
|
||||
const nativeRequestAnimationFrame =
|
||||
globalThis.requestAnimationFrame.bind(globalThis);
|
||||
const nativeCancelAnimationFrame =
|
||||
globalThis.cancelAnimationFrame.bind(globalThis);
|
||||
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
timerStats.timeout.created += 1;
|
||||
let timeoutId = -1;
|
||||
const wrapped =
|
||||
typeof handler === "function"
|
||||
? (...handlerArgs: unknown[]) => {
|
||||
if (timeoutHandles.delete(timeoutId)) {
|
||||
timerStats.timeout.fired += 1;
|
||||
timerStats.timeout.active = timeoutHandles.size;
|
||||
}
|
||||
return handler(...handlerArgs);
|
||||
}
|
||||
: handler;
|
||||
|
||||
timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number;
|
||||
timeoutHandles.set(timeoutId, true);
|
||||
timerStats.timeout.active = timeoutHandles.size;
|
||||
return timeoutId as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
|
||||
globalThis.clearTimeout = ((timeoutId?: number) => {
|
||||
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
|
||||
timerStats.timeout.cleared += 1;
|
||||
timerStats.timeout.active = timeoutHandles.size;
|
||||
}
|
||||
return nativeClearTimeout(timeoutId);
|
||||
}) as typeof clearTimeout;
|
||||
|
||||
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
timerStats.interval.created += 1;
|
||||
let intervalId = -1;
|
||||
const wrapped =
|
||||
typeof handler === "function"
|
||||
? (...handlerArgs: unknown[]) => {
|
||||
timerStats.interval.fired += 1;
|
||||
return handler(...handlerArgs);
|
||||
}
|
||||
: handler;
|
||||
|
||||
intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number;
|
||||
intervalHandles.set(intervalId, true);
|
||||
timerStats.interval.active = intervalHandles.size;
|
||||
return intervalId as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval;
|
||||
|
||||
globalThis.clearInterval = ((intervalId?: number) => {
|
||||
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {
|
||||
timerStats.interval.cleared += 1;
|
||||
timerStats.interval.active = intervalHandles.size;
|
||||
}
|
||||
return nativeClearInterval(intervalId);
|
||||
}) as typeof clearInterval;
|
||||
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
timerStats.raf.created += 1;
|
||||
let rafId = -1;
|
||||
const wrapped = (timestamp: number) => {
|
||||
if (rafHandles.delete(rafId)) {
|
||||
timerStats.raf.fired += 1;
|
||||
timerStats.raf.active = rafHandles.size;
|
||||
}
|
||||
callback(timestamp);
|
||||
};
|
||||
rafId = nativeRequestAnimationFrame(wrapped) as unknown as number;
|
||||
rafHandles.set(rafId, true);
|
||||
timerStats.raf.active = rafHandles.size;
|
||||
return rafId as unknown as ReturnType<typeof requestAnimationFrame>;
|
||||
}) as typeof requestAnimationFrame;
|
||||
|
||||
globalThis.cancelAnimationFrame = ((rafId: number) => {
|
||||
if (rafHandles.delete(rafId)) {
|
||||
timerStats.raf.cleared += 1;
|
||||
timerStats.raf.active = rafHandles.size;
|
||||
}
|
||||
return nativeCancelAnimationFrame(rafId);
|
||||
}) as typeof cancelAnimationFrame;
|
||||
|
||||
const NativeWebSocket = globalThis.WebSocket;
|
||||
if (typeof NativeWebSocket === "function") {
|
||||
class InstrumentedWebSocket extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
if (protocols === undefined) {
|
||||
super(url);
|
||||
} else {
|
||||
super(url, protocols);
|
||||
}
|
||||
const urlKey = String(url);
|
||||
websocketStats.created += 1;
|
||||
websocketStats.active += 1;
|
||||
activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1);
|
||||
|
||||
const handleOpen = () => {
|
||||
websocketStats.opened += 1;
|
||||
};
|
||||
const handleError = () => {
|
||||
websocketStats.errored += 1;
|
||||
};
|
||||
const handleClose = () => {
|
||||
websocketStats.closed += 1;
|
||||
websocketStats.active = Math.max(0, websocketStats.active - 1);
|
||||
const current = activeWsByUrl.get(urlKey) ?? 0;
|
||||
if (current <= 1) {
|
||||
activeWsByUrl.delete(urlKey);
|
||||
} else {
|
||||
activeWsByUrl.set(urlKey, current - 1);
|
||||
}
|
||||
this.removeEventListener("open", handleOpen);
|
||||
this.removeEventListener("error", handleError);
|
||||
this.removeEventListener("close", handleClose);
|
||||
};
|
||||
|
||||
this.addEventListener("open", handleOpen);
|
||||
this.addEventListener("error", handleError);
|
||||
this.addEventListener("close", handleClose);
|
||||
}
|
||||
}
|
||||
globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
||||
}
|
||||
|
||||
const store: ScrollInvestigationStore = {
|
||||
markRender(componentId: string) {
|
||||
const stats = ensureComponentStats(componentStatsById, componentId);
|
||||
stats.renders += 1;
|
||||
stats.lastRenderAtMs = performance.now();
|
||||
},
|
||||
markEvent(componentId: string, event) {
|
||||
const stats = ensureComponentStats(componentStatsById, componentId);
|
||||
switch (event) {
|
||||
case "mount":
|
||||
stats.mounts += 1;
|
||||
return;
|
||||
case "unmount":
|
||||
stats.unmounts += 1;
|
||||
return;
|
||||
case "scrollEvent":
|
||||
stats.scrollEvents += 1;
|
||||
return;
|
||||
case "nearBottomTransition":
|
||||
stats.nearBottomTransitions += 1;
|
||||
return;
|
||||
case "metricUpdate":
|
||||
stats.metricUpdates += 1;
|
||||
return;
|
||||
case "itemRenderCall":
|
||||
stats.itemRenderCalls += 1;
|
||||
return;
|
||||
case "wheelAttach":
|
||||
stats.wheelAttach += 1;
|
||||
return;
|
||||
case "wheelDetach":
|
||||
stats.wheelDetach += 1;
|
||||
return;
|
||||
case "inputChange":
|
||||
stats.inputChanges += 1;
|
||||
return;
|
||||
case "keyPress":
|
||||
stats.keyPresses += 1;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
const activeByTypeAndTarget: Record<string, Record<string, number>> = {};
|
||||
for (const { type, target } of activeListenerMeta.values()) {
|
||||
const existingByType = activeByTypeAndTarget[type] ?? {};
|
||||
existingByType[target] = (existingByType[target] ?? 0) + 1;
|
||||
activeByTypeAndTarget[type] = existingByType;
|
||||
}
|
||||
return {
|
||||
listeners: {
|
||||
byType: Object.fromEntries(listenerStatsByType.entries()),
|
||||
byCallsite: Object.fromEntries(listenerCallsiteCount.entries()),
|
||||
activeUniqueKeys: activeListenerKeys.size,
|
||||
activeByTypeAndTarget,
|
||||
},
|
||||
timers: {
|
||||
timeout: { ...timerStats.timeout, active: timeoutHandles.size },
|
||||
interval: { ...timerStats.interval, active: intervalHandles.size },
|
||||
raf: { ...timerStats.raf, active: rafHandles.size },
|
||||
},
|
||||
websockets: {
|
||||
totals: { ...websocketStats },
|
||||
activeByUrl: Object.fromEntries(activeWsByUrl.entries()),
|
||||
},
|
||||
components: Object.fromEntries(componentStatsById.entries()),
|
||||
};
|
||||
},
|
||||
printSnapshot(label?: string) {
|
||||
console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot());
|
||||
},
|
||||
_installedAtMs: Date.now(),
|
||||
};
|
||||
|
||||
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
|
||||
console.log(
|
||||
`${SOURCE_LABEL} installed`,
|
||||
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()"
|
||||
);
|
||||
}
|
||||
|
||||
function getStore(): ScrollInvestigationStore | null {
|
||||
const runtime = globalThis as ScrollInvestigationGlobal;
|
||||
return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null;
|
||||
}
|
||||
|
||||
export function markScrollInvestigationRender(componentId: string): void {
|
||||
if (!shouldInstall()) {
|
||||
return;
|
||||
}
|
||||
getStore()?.markRender(componentId);
|
||||
}
|
||||
|
||||
export function markScrollInvestigationEvent(
|
||||
componentId: string,
|
||||
event:
|
||||
| "mount"
|
||||
| "unmount"
|
||||
| "scrollEvent"
|
||||
| "nearBottomTransition"
|
||||
| "metricUpdate"
|
||||
| "itemRenderCall"
|
||||
| "wheelAttach"
|
||||
| "wheelDetach"
|
||||
| "inputChange"
|
||||
| "keyPress"
|
||||
): void {
|
||||
if (!shouldInstall()) {
|
||||
return;
|
||||
}
|
||||
getStore()?.markEvent(componentId, event);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
type ScrollInvestigationEvent =
|
||||
| "scrollEvent"
|
||||
| "nearBottomTransition"
|
||||
| "metricUpdate"
|
||||
| "itemRenderCall"
|
||||
| "wheelAttach"
|
||||
| "wheelDetach";
|
||||
|
||||
export function installScrollJankInvestigation(): void {}
|
||||
|
||||
export function markScrollInvestigationRender(_componentId: string): void {}
|
||||
|
||||
export function markScrollInvestigationEvent(
|
||||
_componentId: string,
|
||||
_event: ScrollInvestigationEvent
|
||||
): void {}
|
||||
@@ -48,16 +48,25 @@ const daemonClientMock = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const clientIdMock = vi.hoisted(() => ({
|
||||
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
|
||||
}));
|
||||
|
||||
vi.mock("@server/client/daemon-client", () => ({
|
||||
DaemonClient: daemonClientMock.MockDaemonClient,
|
||||
}));
|
||||
|
||||
vi.mock("./client-id", () => ({
|
||||
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
||||
}));
|
||||
|
||||
describe("test-daemon-connection probe client identity", () => {
|
||||
beforeEach(() => {
|
||||
daemonClientMock.createdConfigs.length = 0;
|
||||
clientIdMock.getOrCreateClientId.mockClear();
|
||||
});
|
||||
|
||||
it("uses isolated probe clientId values for direct latency probes", async () => {
|
||||
it("reuses the app clientId for direct latency probes", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
await mod.measureConnectionLatency({
|
||||
@@ -72,8 +81,8 @@ describe("test-daemon-connection probe client identity", () => {
|
||||
});
|
||||
|
||||
const [first, second] = daemonClientMock.createdConfigs;
|
||||
expect(first?.clientId).toMatch(/^cid_probe_/);
|
||||
expect(second?.clientId).toMatch(/^cid_probe_/);
|
||||
expect(first?.clientId).not.toBe(second?.clientId);
|
||||
expect(first?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(second?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import type { HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { getOrCreateClientId } from "./client-id";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||
function createProbeClientId(): string {
|
||||
const randomUuid = (() => {
|
||||
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||||
return cryptoObj.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
})();
|
||||
return `cid_probe_${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -54,7 +45,7 @@ async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = createProbeClientId();
|
||||
const clientId = await getOrCreateClientId();
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
|
||||
Reference in New Issue
Block a user