Merge origin/main into local-streaming-speech-tts

This commit is contained in:
Mohamed Boudra
2026-02-06 13:45:31 +07:00
52 changed files with 2977 additions and 1059 deletions
+2
View File
@@ -9,7 +9,9 @@ out/
# Environment variables
.env
.env.test
.env.local
.env.test.local
.env*.local
# Logs
+2 -5
View File
@@ -3,6 +3,7 @@ import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "
import { createTempGitRepo } from "./helpers/workspace";
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
test.setTimeout(120_000);
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
@@ -12,9 +13,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
await ensureHostSelected(page);
await createAgent(page, prompt);
// Wait for the agent to finish responding
await page.waitForTimeout(2000);
await page.getByTestId("agent-overflow-menu").click();
await page.getByTestId("agent-menu-details").click();
@@ -28,7 +26,7 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
await expect(
page.getByTestId("agent-details-persistence-session-id-value")
).not.toHaveText("Not available");
).not.toHaveText("Not available", { timeout: 90_000 });
await page.getByTestId("agent-details-agent-id").click();
await expect(page.getByTestId("app-toast")).toBeVisible();
@@ -37,4 +35,3 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
await repo.cleanup();
}
});
+164 -65
View File
@@ -1,13 +1,12 @@
import path from 'node:path';
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
allowPermission,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
waitForPermissionPrompt,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
@@ -21,22 +20,30 @@ function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
function getChangesActionLabel(page: Page, label: string) {
return getChangesScope(page).getByText(label, { exact: true });
}
function getChangesActionButton(page: Page, label: string) {
return getChangesActionLabel(page, label).locator('..');
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
const scope = getChangesScope(page);
await scope.getByTestId('changes-view-selector').click();
if (view === 'working') {
await page.getByTestId('changes-mode-uncommitted').click();
} else {
await page.getByTestId('changes-mode-base').click();
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
if (await primaryBackdrop.isVisible().catch(() => false)) {
await primaryBackdrop.click({ force: true });
await expect(primaryBackdrop).toHaveCount(0);
}
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
if (await overflowBackdrop.isVisible().catch(() => false)) {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
if (!(await modeToggle.isVisible().catch(() => false))) {
return;
}
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesOverflowMenu(page: Page) {
@@ -45,15 +52,13 @@ async function openChangesOverflowMenu(page: Page) {
await menuButton.click();
}
async function openChangesShipMenu(page: Page) {
const backdrop = page.getByRole('button', { name: 'Ship menu backdrop' }).first();
if (await backdrop.isVisible().catch(() => false)) {
return;
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-ship-caret').first();
const caret = scope.getByTestId('changes-primary-cta-caret').first();
await expect(caret).toBeVisible();
await caret.click();
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
@@ -61,15 +66,23 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
await page.getByText('Changes', { exact: true }).click();
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', { exact: true }).click();
await page.getByText(/view changes/i).first().click();
}
}
await expect(changesHeader).toBeVisible();
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
return;
}
@@ -142,10 +155,24 @@ async function selectAttachWorktree(page: Page, branchName: string) {
}, { timeout: 10000 }).toBeTruthy();
const sheetVisible = await sheet.isVisible().catch(() => false);
const scope = sheetVisible ? sheet : page;
const option = scope.getByText(branchName, { exact: true }).first();
await expect(option).toBeVisible();
await option.click();
await expect(picker).toContainText(branchName);
const preferredOption = scope.getByText(branchName, { exact: true }).first();
if (await preferredOption.isVisible().catch(() => false)) {
await preferredOption.click();
await expect(picker).toContainText(branchName);
return;
}
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);
}
}
async function enableCreateWorktree(page: Page) {
@@ -194,10 +221,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await waitForAssistantText(page, 'READY');
await openChangesPanel(page);
const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim();
expect(branchName.length).toBeGreaterThan(0);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
await expect
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
.not.toBe('Unknown');
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const firstCwd = await requestCwd(page);
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: firstCwd,
encoding: 'utf8',
}).trim();
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
realpath(repo.path).catch(() => repo.path),
@@ -206,16 +242,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
const normalizedCwd = normalizeTmpPath(resolvedCwd);
const expectedMarker = `${path.sep}worktrees${path.sep}`;
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
if (repo.name) {
expect(normalizedCwd.includes(path.join('worktrees', repo.name))).toBeTruthy();
}
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, branchName);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAssistantText(page, 'READY2');
@@ -234,21 +267,50 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toContainText('Commit');
await getChangesScope(page).getByTestId('changes-action-commit').click();
await primaryCta.click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push once from the menu so the branch has an origin/<branch> ref.
await openChangesPrimaryMenu(page);
await page.getByTestId('changes-menu-push').click();
await expect
.poll(() => {
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
return true;
} catch {
return false;
}
}, { timeout: 30000 })
.toBe(true);
const notesPath = path.join(firstCwd, 'notes.txt');
await writeFile(notesPath, 'Second change\n');
@@ -258,17 +320,31 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('changes-action-commit').click();
await getChangesScope(page).getByTestId('changes-primary-cta').click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
@@ -278,50 +354,73 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await openChangesShipMenu(page);
await page.getByTestId('changes-ship-create-pr').click();
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
timeout: 60000,
});
await openChangesShipMenu(page);
await expect(page.getByTestId('changes-ship-open-pr')).toBeVisible();
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
await pushPrimary.click();
// Regression check: the primary CTA stays in place while pushing.
await expect(pushPrimary).toBeVisible();
await page.waitForTimeout(50);
await expect(pushPrimary).toBeVisible();
await expect
.poll(() => {
try {
const count = execSync(
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
{ cwd: firstCwd, encoding: 'utf8' }
).trim();
return Number.parseInt(count, 10);
} catch {
return null;
}
}, { timeout: 30000 })
.toBe(0);
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
execSync("git checkout main", { cwd: repo.path });
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
execSync("git push", { cwd: repo.path });
await page.getByTestId('changes-ship-merge').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
timeout: 60000,
});
await refreshChangesTab(page);
await expect(getChangesScope(page).getByTestId('changes-ship-caret')).toHaveCount(0, { timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
await page.getByTestId('changes-menu-archive').click();
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(branchName, { exact: true })).toHaveCount(0);
const attachSheetBackdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' });
if (await attachSheetBackdrop.isVisible()) {
await attachSheetBackdrop.click({ force: true });
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 });
await setWorkingDirectory(page, nonGitDir);
const attachPicker = page.getByTestId('worktree-attach-picker');
if (await attachPicker.isVisible()) {
await page.getByTestId('worktree-attach-toggle').click();
await expect(attachPicker).toBeHidden({ timeout: 30000 });
}
// 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-toolbar')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();
+1 -1
View File
@@ -18,7 +18,7 @@ test('create agent in a temp repo', async ({ page }) => {
// Verify we used a fast model (do not fall back to a default like Sonnet).
await page.getByTestId('agent-overflow-menu').click();
await expect(page.getByText('Model', { exact: true })).toBeVisible();
await expect(page.getByText(/haiku/i)).toBeVisible();
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
// Wait for agent response containing "Hello" within an assistant message
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
+5 -1
View File
@@ -3,13 +3,17 @@ import { gotoHome, openSettings } from './helpers/app';
test('daemon is connected in settings', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await gotoHome(page);
await openSettings(page);
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
await expect(page.getByTestId('daemon-card-e2e-test-daemon').getByText('Online', { exact: true })).toBeVisible();
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
});
+11 -23
View File
@@ -1,26 +1,11 @@
import path from 'node:path';
import type { Locator, Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
async function longPress(page: Page, locator: Locator, durationMs = 1100) {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Expected long-press target to have a bounding box.');
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(durationMs);
await page.mouse.up();
}
test('deleting an agent via long-press persists after reload', async ({ page }) => {
test('deleting an agent persists after reload', async ({ page }) => {
const repo = await createTempGitRepo();
const nonce = Math.random().toString(36).slice(2, 10);
const prompt = `delete-agent-persists-${nonce}`;
const prompt = `respond-ready-${nonce}`;
try {
await gotoHome(page);
@@ -33,6 +18,10 @@ test('deleting an agent via long-press persists after reload', async ({ page })
await input.fill(prompt);
await input.press('Enter');
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
// 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\/([^/]+)\/([^/?#]+)/);
if (!match) {
@@ -47,12 +36,11 @@ test('deleting an agent via long-press persists after reload', async ({ page })
const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 });
await longPress(page, agentRow, 1200);
const archiveButton = page.getByTestId('agent-action-archive').first();
await expect(archiveButton).toBeVisible({ timeout: 10000 });
await archiveButton.click({ force: true });
await expect(page.getByTestId('agent-action-cancel')).toHaveCount(0, { timeout: 10000 });
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true });
// Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
+10 -2
View File
@@ -137,10 +137,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
const transcriptLocator = page.getByText(/voice note/i);
await expect(transcriptLocator).toBeVisible({ timeout: 60_000 });
await expect
.poll(
async () => page.getByRole('button', { name: 'Copy message' }).count(),
{ timeout: 60_000 }
)
.toBeGreaterThan(initialCopyMessageCount);
} finally {
await repo.cleanup();
}
@@ -57,7 +57,6 @@ test('keeps file header sticky while scrolling within a long diff', async ({ pag
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await expect(page.getByText('READY', { exact: true })).toBeVisible({ timeout: 60000 });
await openChangesPanel(page);
+8
View File
@@ -1,9 +1,11 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -72,6 +74,12 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
const envTestPath = path.join(repoRoot, '.env.test');
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
const port = await getAvailablePort();
const relayPort = await getAvailablePort();
const metroPort = await getAvailablePort();
+5 -5
View File
@@ -425,18 +425,18 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByText('How would you like to proceed?').first();
const promptText = page.getByTestId('permission-request-question').first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const allowButton = page.getByText('Allow', { exact: true }).first();
await expect(allowButton).toBeVisible({ timeout: 5000 });
await allowButton.click();
const acceptButton = page.getByTestId('permission-request-accept').first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByText('Deny', { exact: true }).first();
const denyButton = page.getByTestId('permission-request-deny').first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
+7 -29
View File
@@ -1,12 +1,10 @@
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
type TempRepo = {
path: string;
owner?: string;
name?: string;
cleanup: () => Promise<void>;
};
@@ -15,10 +13,6 @@ export const createTempGitRepo = async (
options?: { withRemote?: boolean }
): Promise<TempRepo> => {
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
const repoName = `paseo-e2e-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
let owner: string | undefined;
const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
@@ -29,33 +23,17 @@ export const createTempGitRepo = async (
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
if (withRemote) {
try {
owner = execSync('gh api user -q .login', { encoding: 'utf8' }).trim();
execSync(
`gh repo create ${repoName} --private --confirm --source=. --remote=origin --push`,
{
cwd: repoPath,
stdio: 'ignore',
}
);
} catch (error) {
await rm(repoPath, { recursive: true, force: true });
throw error;
}
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, 'remote.git');
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
}
return {
path: repoPath,
owner,
name: repoName,
cleanup: async () => {
if (owner && withRemote) {
try {
execSync(`gh repo delete ${owner}/${repoName} --yes`, { stdio: 'ignore' });
} catch {
// Best-effort cleanup
}
}
await rm(repoPath, { recursive: true, force: true });
},
};
@@ -2,9 +2,13 @@ import { test, expect } from './fixtures';
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
@@ -18,12 +22,21 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
await page.getByText('Direct connection', { exact: true }).click();
await page.getByPlaceholder('My Host').fill('E2E Host');
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect & Save', { exact: true }).click();
await page.getByText('Connect', { exact: true }).click();
// First-time connection prompts for an optional label.
const nameModal = page.getByTestId('name-host-modal');
const showedNameModal = await nameModal
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (showedNameModal) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText('E2E Host', { exact: true })).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
});
+4 -5
View File
@@ -78,11 +78,10 @@ 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();
const removeButtons = page.getByText('Remove', { exact: true });
await expect(removeButtons).toHaveCount(2);
page.once('dialog', (dialog) => dialog.accept());
await removeButtons.nth(1).click();
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click();
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction(
+6 -2
View File
@@ -17,14 +17,18 @@ test('manual host add accepts host:port only and persists a direct connection',
});
await page.goto('/settings');
await page.getByText('+ Add host', { exact: true }).click();
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click();
const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect & Save', { exact: true }).click();
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();
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
+19 -21
View File
@@ -7,19 +7,26 @@ import {
waitForPermissionPrompt,
allowPermission,
denyPermission,
waitForAgentFinishUI,
getToolCallCount,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
const FILE_CONTENT = 'Hello from permission test';
function buildWriteCommand(filePath: string): string {
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
}
test.describe('permission prompts', () => {
test('allow permission creates the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-allow-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
@@ -31,11 +38,6 @@ test.describe('permission prompts', () => {
await waitForPermissionPrompt(page, 30000);
// Check tool call count before allowing permission
// In "Always Ask" mode, we should see the permission prompt badge
const toolCallCountBefore = await getToolCallCount(page);
expect(toolCallCountBefore).toBe(1);
await allowPermission(page);
// Wait for file to be created
@@ -59,7 +61,12 @@ test.describe('permission prompts', () => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-deny-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
@@ -71,23 +78,14 @@ test.describe('permission prompts', () => {
await waitForPermissionPrompt(page, 30000);
// Check tool call count before denying permission
// In "Always Ask" mode, we should see the permission prompt badge
const toolCallCountBefore = await getToolCallCount(page);
expect(toolCallCountBefore).toBe(1);
await denyPermission(page);
// After denying permission, wait for the agent to show the permission denied result
// The agent might stay in running state but should show a tool call result
await page.waitForTimeout(3000); // Give time for the denial to be processed
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
timeout: 30_000,
});
expect(existsSync(filePath)).toBe(false);
// After denying, the tool call count should still be 1
// The UI doesn't show a separate badge for denied permissions
const toolCallCountAfter = await getToolCallCount(page);
expect(toolCallCountAfter).toBe(1);
} finally {
await repo.cleanup();
}
@@ -0,0 +1,47 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
await gotoHome(page);
await openSettings(page);
const menuButton = page.getByRole('button', { name: /menu/i }).first();
await expect(menuButton).toBeVisible();
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
await menuButton.focus();
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();
// Tooltip should also appear on hover.
await menuButton.blur();
await expect(tooltip).toHaveCount(0);
await menuButton.hover();
await expect(tooltip).toBeVisible();
const triggerBox = await menuButton.boundingBox();
const tooltipBox = await tooltip.boundingBox();
expect(triggerBox).not.toBeNull();
expect(tooltipBox).not.toBeNull();
if (!triggerBox || !tooltipBox) return;
// side=right => tooltip starts to the right of the trigger.
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
// Keep it reasonably close (should be ~trigger.right + offset).
const expectedX = triggerBox.x + triggerBox.width + 8;
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
expect(tooltipBox.width).toBeLessThanOrEqual(500);
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
expect(tooltipBox.height).toBeLessThanOrEqual(80);
// align=center => centers should be roughly aligned (allow some clamping tolerance).
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
});
+2 -16
View File
@@ -15,7 +15,7 @@ import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
@@ -37,6 +37,7 @@ import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { queryClient } from "@/query/query-client";
polyfillCrypto();
@@ -98,21 +99,6 @@ function PushNotificationRouter() {
}
function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
@@ -222,6 +222,11 @@ export function DropdownSheet({
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose();
}, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
@@ -267,6 +272,15 @@ export function DropdownSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
onPress={handleClose}
hitSlop={10}
testID="dropdown-sheet-close"
>
<X size={18} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
@@ -990,11 +1004,12 @@ export function GitOptionsSection({
visible={isWorktreeSheetOpen}
onClose={() => setIsWorktreeSheetOpen(false)}
>
{worktreeOptions.map((option) => (
{worktreeOptions.map((option, index) => (
<SelectOption
key={option.path}
label={option.label}
selected={option.path === selectedWorktreePath}
testID={`worktree-attach-option-${index}`}
onPress={() => {
onSelectWorktreePath(option.path);
setIsWorktreeSheetOpen(false);
@@ -1094,6 +1109,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2],
},
@@ -84,6 +84,11 @@ export interface AgentStreamViewProps {
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
/**
* True when we expect a history snapshot to arrive shortly (e.g. after opening an agent
* or resuming after connectivity changes) and should avoid showing the "empty" state.
*/
isSyncingHistory?: boolean;
}
export function AgentStreamView({
@@ -92,6 +97,7 @@ export function AgentStreamView({
agent,
streamItems,
pendingPermissions,
isSyncingHistory = false,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
@@ -590,6 +596,43 @@ export function AgentStreamView({
return { marginBottom: tightGap };
}, [listHeaderComponent, tightGap]);
const listEmptyComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = (streamHead?.length ?? 0) > 0;
if (hasPermissions || hasHeadItems) {
return null;
}
const shouldShowSyncing =
isSyncingHistory || agent.status === "running";
if (shouldShowSyncing) {
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
</View>
);
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
);
}, [
agent.status,
isSyncingHistory,
pendingPermissionItems.length,
streamHead,
theme.colors.foregroundMuted,
]);
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
@@ -607,13 +650,7 @@ export function AgentStreamView({
style={stylesheet.list}
onScroll={handleScroll}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListEmptyComponent={listEmptyComponent}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
@@ -1071,6 +1108,7 @@ function PermissionRequestCard({
) : null}
<Text
testID="permission-request-question"
style={[
permissionStyles.question,
{ color: theme.colors.mutedForeground },
@@ -1086,6 +1124,7 @@ function PermissionRequestCard({
]}
>
<Pressable
testID="permission-request-deny"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -1126,6 +1165,7 @@ function PermissionRequestCard({
</Pressable>
<Pressable
testID="permission-request-accept"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -263,37 +263,33 @@ function SidebarContent({
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
// If not a git repo, only show files tab
const effectiveTab = isGit ? activeTab : "files";
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit ? (
<Pressable
style={[styles.tab, effectiveTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
effectiveTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
) : null}
<Pressable
style={[styles.tab, effectiveTab === "files" && styles.tabActive]}
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
!isGit && styles.tabTextMuted,
]}
>
Changes
</Text>
</Pressable>
<Pressable
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
effectiveTab === "files" && styles.tabTextActive,
activeTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -311,10 +307,10 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{effectiveTab === "changes" && (
{activeTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{effectiveTab === "files" && (
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
</View>
@@ -389,6 +385,9 @@ const styles = StyleSheet.create((theme) => ({
tabTextActive: {
color: theme.colors.foreground,
},
tabTextMuted: {
opacity: 0.8,
},
headerRightSection: {
flexDirection: "row",
alignItems: "center",
@@ -36,7 +36,11 @@ import {
MoreVertical,
X,
} from "lucide-react-native";
import type { ExplorerEntry, ExplorerFile } from "@/stores/session-store";
import type {
AgentFileExplorerState,
ExplorerEntry,
ExplorerFile,
} from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -312,6 +316,11 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const showInitialLoading =
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
const minTreeWidth = 220;
@@ -475,6 +484,17 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
[]
);
const handleBackFromError = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
selectExplorerEntry(agentId, null);
requestDirectoryListing(agentId, errorRecoveryPath, {
recordHistory: false,
setCurrentPath: true,
});
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
if (!agentExists) {
return (
<View style={styles.centerState}>
@@ -491,19 +511,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable
style={styles.retryButton}
onPress={() => {
if (agentId) {
requestDirectoryListing(agentId, ".", {
recordHistory: false,
setCurrentPath: false,
});
}
}}
>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
<View style={styles.errorActions}>
{showBackFromError ? (
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
<Text style={styles.retryButtonText}>Back</Text>
</Pressable>
) : null}
<Pressable
style={styles.retryButton}
onPress={() => {
if (agentId) {
requestDirectoryListing(agentId, ".", {
recordHistory: false,
setCurrentPath: false,
});
}
}}
>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
</View>
) : showInitialLoading ? (
<View style={styles.centerState}>
@@ -960,6 +987,21 @@ function getAncestorDirectories(directory: string): string[] {
return ancestors;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) {
return ".";
}
const currentHistoryPath =
state.history.length > 0 ? state.history[state.history.length - 1] : null;
const candidate = currentHistoryPath ?? state.lastVisitedPath ?? state.currentPath;
if (!candidate || candidate.length === 0) {
return ".";
}
return candidate;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -1065,6 +1107,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
errorActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
+108 -226
View File
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
import type { UseMutationResult } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import {
View,
@@ -17,8 +16,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
@@ -40,20 +38,6 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
// =============================================================================
// Action Status Hook
// =============================================================================
// Tracks mutation state with a brief success phase before returning to idle.
// State flow: idle → pending → success (1s) → idle
// =============================================================================
const SUCCESS_DISPLAY_MS = 1000;
type ActionState = {
status: ActionStatus;
trigger: () => void;
};
// =============================================================================
// Git Actions Data Structure
// =============================================================================
@@ -85,47 +69,6 @@ interface GitActions {
menu: GitAction[];
}
function useActionStatus<TData, TError, TVariables, TContext>(
mutation: UseMutationResult<TData, TError, TVariables, TContext>,
onTrigger?: () => void
): ActionState {
const [showSuccess, setShowSuccess] = useState(false);
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
};
}, []);
// Watch for mutation success to trigger success display
useEffect(() => {
if (mutation.isSuccess && !mutation.isPending) {
setShowSuccess(true);
successTimeoutRef.current = setTimeout(() => {
setShowSuccess(false);
mutation.reset();
}, SUCCESS_DISPLAY_MS);
}
}, [mutation.isSuccess, mutation.isPending, mutation]);
const status: ActionStatus = mutation.isPending
? "pending"
: showSuccess
? "success"
: "idle";
const trigger = useCallback(() => {
onTrigger?.();
mutation.mutate(undefined as TVariables);
}, [mutation, onTrigger]);
return { status, trigger };
}
function openURLInNewTab(url: string): void {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener");
@@ -480,10 +423,6 @@ type GitDiffSection = {
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const router = useRouter();
const queryClient = useQueryClient();
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -669,157 +608,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
const commitMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
},
});
});
}, [runCommit, serverId, cwd]);
const prMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshPrStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
const mergeMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
},
});
const mergeFromBaseMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
},
});
const pushMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshStatus();
},
onError: (err) => {
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
},
});
});
}, [runPush, serverId, cwd]);
const archiveMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const worktreePath = status?.cwd;
if (!worktreePath) {
throw new Error("Worktree path unavailable");
}
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
router.replace("/agent" as any);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
// Wrap mutations with action status for UI feedback
const commitAction = useActionStatus(commitMutation);
const prCreateAction = useActionStatus(prMutation, () => void persistShipDefault("pr"));
const mergeAction = useActionStatus(mergeMutation, () => void persistShipDefault("merge"));
const mergeFromBaseAction = useActionStatus(mergeFromBaseMutation);
const pushAction = useActionStatus(pushMutation);
const archiveAction = useActionStatus(archiveMutation);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
return;
}
setActionError(null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace("/agent" as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
({ item, section }) => (
@@ -861,16 +741,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const commitDisabled = actionsDisabled || commitMutation.isPending;
const prDisabled = actionsDisabled || prMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges;
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
const pushDisabled =
actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled =
actionsDisabled ||
archiveMutation.isPending ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
let bodyContent: ReactElement;
@@ -966,8 +847,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitAction.status,
handler: commitAction.trigger,
status: commitStatus,
handler: handleCommit,
});
// Push - when has remote
@@ -978,9 +859,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushAction.status,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
handler: pushAction.trigger,
handler: handlePush,
});
}
@@ -1006,8 +887,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateAction.status,
handler: prCreateAction.trigger,
status: prCreateStatus,
handler: handleCreatePr,
});
}
@@ -1019,9 +900,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeAction.status,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeAction.trigger,
handler: handleMergeBranch,
});
}
@@ -1033,9 +914,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
status: mergeFromBaseStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
handler: handleMergeFromBase,
});
}
@@ -1047,9 +928,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveAction.status,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: archiveAction.trigger,
handler: handleArchiveWorktree,
});
}
@@ -1061,7 +942,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
@@ -1103,7 +984,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
]);
// Helper to get display label based on status
@@ -1216,7 +1098,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
) : null}
</View>
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
{isGit ? (
<View style={styles.diffStatusContainer}>
<View style={styles.diffStatusInner}>
<Pressable
+194 -104
View File
@@ -12,6 +12,7 @@ import {
useMemo,
useState,
useEffect,
useRef,
type ReactElement,
type MutableRefObject,
} from "react";
@@ -24,7 +25,6 @@ import {
DraggableList,
type DraggableRenderItemInfo,
} from "./draggable-list";
import { formatTimeAgo } from "@/utils/time";
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
@@ -185,6 +185,164 @@ function SectionHeader({
);
}
interface GroupedAgentRowProps {
agent: AggregatedAgent;
isSelected: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: (e: { stopPropagation: () => void }) => void;
}
function GroupedAgentRow({
agent,
isSelected,
shortcutNumber,
onPress,
onLongPress,
onArchive,
}: GroupedAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
return;
}
clearTimeout(hoverOutTimeoutRef.current);
hoverOutTimeoutRef.current = null;
}, []);
useEffect(() => {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
}, [clearHoverOutTimeout]);
const handleHoverOut = useCallback(() => {
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
setIsArchiveConfirmVisible(false);
}, 50);
}, [clearHoverOutTimeout]);
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
const canArchive = agent.status !== "running" && !agent.requiresAttention;
const showArchive = canArchive && shortcutNumber === null && (isHovered || isArchiveConfirmVisible);
return (
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={onPress}
onPressIn={() => {
if (Platform.OS !== "web") {
return;
}
handleHoverIn();
}}
onLongPress={onLongPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : showArchive ? (
<Pressable
style={[
styles.branchBadge,
isArchiveConfirmVisible && styles.archiveConfirmBadge,
]}
onPress={(e) => {
e.stopPropagation();
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive(e);
setIsArchiveConfirmVisible(false);
}}
testID={
isArchiveConfirmVisible
? `agent-archive-confirm-${agent.serverId}-${agent.id}`
: `agent-archive-${agent.serverId}-${agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
<Text
style={[
styles.branchBadgeText,
styles.archiveConfirmText,
archiveHovered && styles.archiveConfirmTextHovered,
]}
numberOfLines={1}
>
Confirm
</Text>
) : (
<Archive
size={12}
color={
archiveHovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
}
export function GroupedAgentList({
agents,
isRefreshing = false,
@@ -337,107 +495,6 @@ export function GroupedAgentList({
[]
);
const AgentListRow = useCallback(
({ agent }: { agent: AggregatedAgent }) => {
const [isHovered, setIsHovered] = useState(false);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const isRunning = agent.status === "running";
const shortcutNumber =
showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
const canArchive = !isRunning && !agent.requiresAttention;
return (
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : isHovered && canArchive ? (
<Pressable
style={styles.branchBadge}
onPress={(e) => handleArchiveAgent(e, agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(true)}
testID={`agent-archive-${agent.serverId}-${agent.id}`}
>
{({ hovered: archiveHovered }) => (
<Archive
size={12}
color={
archiveHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
},
[
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
theme.colors.foreground,
theme.colors.foregroundMuted,
]
);
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
@@ -454,12 +511,34 @@ export function GroupedAgentList({
/>
{!isCollapsed &&
section.agents.map((agent) => (
<AgentListRow key={`${agent.serverId}:${agent.id}`} agent={agent} />
<GroupedAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
? (shortcutIndexByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null)
: null
}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onArchive={(e) => handleArchiveAgent(e, agent)}
/>
))}
</View>
);
},
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
[
collapsedProjectKeys,
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
handleCreateAgentInProject,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
toggleProjectCollapsed,
]
);
const keyExtractor = useCallback(
@@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
lineHeight: 12,
},
archiveConfirmBadge: {
minWidth: 76,
maxWidth: 9999,
flexShrink: 0,
},
archiveConfirmText: {
color: theme.colors.foreground,
},
archiveConfirmTextHovered: {
opacity: 0.9,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
opacity: 1,
@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
export function HeaderToggleButton({
onPress,
tooltipLabel,
tooltipKeys,
tooltipSide,
tooltipDelayDuration = 0,
style,
disabled,
children,
...props
}: PropsWithChildren<
Omit<PressableProps, "style" | "onPress"> & {
onPress: NonNullable<PressableProps["onPress"]>;
tooltipLabel: string;
tooltipKeys: ShortcutKey[];
tooltipSide: "left" | "right" | "top" | "bottom";
tooltipDelayDuration?: number;
style?: StyleProp<ViewStyle>;
}
>): ReactElement {
const tooltipTestID =
typeof props.testID === "string" && props.testID.length > 0
? `${props.testID}-tooltip`
: undefined;
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
{...props}
disabled={disabled}
onPress={(e) => {
onPress(e);
}}
style={[styles.button, style]}
>
{children}
</TooltipTrigger>
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
<Shortcut keys={tooltipKeys} style={styles.shortcut} textStyle={styles.shortcutText} />
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
shortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
shortcutText: {
color: theme.colors.popoverForeground,
},
}));
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Text } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
interface MenuHeaderProps {
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
<ScreenHeader
left={
<>
<Pressable
<HeaderToggleButton
onPress={toggleAgentList}
style={styles.menuButton}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</Pressable>
</HeaderToggleButton>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
left: {
gap: theme.spacing[2],
},
menuButton: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.base,
@@ -0,0 +1,37 @@
import type { ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
style,
textStyle,
}: {
keys: ShortcutKey[];
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement {
return (
<View style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
+477
View File
@@ -0,0 +1,477 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import {
Dimensions,
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type TooltipContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
delayDuration: number;
};
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(componentName: string): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip />`);
}
return ctx;
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
align: Align;
offset: number;
}): { x: number; y: number; actualSide: Side } {
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight =
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualSide = "top";
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualSide = "bottom";
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
actualSide = "right";
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
actualSide = "left";
}
let x = 0;
let y = 0;
if (actualSide === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "top") {
y = triggerRect.y - contentHeight - offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "left") {
x = triggerRect.x - contentWidth - offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
} else {
x = triggerRect.x + triggerRect.width + offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
}
const padding = 8;
x = Math.max(
padding,
Math.min(displayArea.width - contentWidth - padding, x)
);
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y)
);
return { x, y, actualSide };
}
export function Tooltip({
open,
defaultOpen,
onOpenChange,
delayDuration = 0,
enabledOnDesktop = true,
enabledOnMobile = false,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
enabledOnDesktop?: boolean;
enabledOnMobile?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const isWeb = Platform.OS === "web";
const isMobileWeb =
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const value = useMemo<TooltipContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
enabled,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration]
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
export function TooltipTrigger({
children,
disabled,
onHoverIn,
onHoverOut,
onFocus,
onBlur,
onPress,
...props
}: PropsWithChildren<PressableProps>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
if (!ctx.enabled || disabled) return;
clearOpenTimer();
if (ctx.delayDuration <= 0) {
ctx.setOpen(true);
return;
}
openTimerRef.current = setTimeout(() => {
ctx.setOpen(true);
openTimerRef.current = null;
}, ctx.delayDuration);
}, [clearOpenTimer, ctx, disabled]);
const close = useCallback(() => {
clearOpenTimer();
ctx.setOpen(false);
}, [clearOpenTimer, ctx]);
useEffect(() => {
return () => {
clearOpenTimer();
};
}, [clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: any) => {
onHoverIn?.(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen]
);
const handleHoverOut = useCallback(
(e?: any) => {
onHoverOut?.(e);
close();
},
[onHoverOut, close]
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onFocus={(e) => {
onFocus?.(e);
if (!ctx.enabled || disabled) return;
clearOpenTimer();
ctx.setOpen(true);
}}
onBlur={(e) => {
onBlur?.(e);
close();
}}
onPress={(e) => {
onPress?.(e);
close();
}}
{...(Platform.OS === "web"
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
: null)}
>
{children}
</Pressable>
);
}
export function TooltipContent({
children,
side = "top",
align = "center",
offset = 6,
style,
testID,
maxWidth = 280,
}: PropsWithChildren<{
side?: Side;
align?: Align;
offset?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(
null
);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
}
const statusBarHeight =
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
});
return () => {
cancelled = true;
};
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
});
setPosition({ x: result.x, y: result.y });
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[]
);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Portal>
);
}
return (
<Modal
visible={ctx.open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: { flex: 1 },
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
content: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
zIndex: 1000,
},
}));
+4 -43
View File
@@ -915,16 +915,6 @@ export function SessionProvider({
});
}
setInitializingAgents(serverId, (prev) => {
const currentState = prev.get(agentId);
if (currentState === false) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes.
@@ -956,7 +946,7 @@ export function SessionProvider({
clearAgentStreamHead(serverId, agentId);
setInitializingAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
@@ -990,16 +980,6 @@ export function SessionProvider({
requestId: (message.payload as any).requestId,
});
}
if (status === "agent_initialized" && "agentId" in message.payload) {
const agentId = (message.payload as any).agentId as string | undefined;
if (agentId) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
}
}
});
const unsubPermissionRequest = client.on(
@@ -1443,12 +1423,6 @@ export function SessionProvider({
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
console.warn("[Session] initializeAgent skipped: daemon unavailable");
setInitializingAgents(serverId, (prev) => {
@@ -1470,24 +1444,17 @@ export function SessionProvider({
});
});
},
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
[serverId, client, setInitializingAgents]
);
const refreshAgent = useCallback(
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
({ agentId }: { agentId: string }) => {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
refreshAgentMutation
.mutateAsync({ agentId })
.catch((error) => {
@@ -1499,13 +1466,7 @@ export function SessionProvider({
});
});
},
[
serverId,
refreshAgentMutation,
setAgentStreamTail,
setInitializingAgents,
clearAgentStreamHead,
]
[serverId, refreshAgentMutation, setInitializingAgents]
);
const sendAgentMessage = useCallback(
@@ -1,19 +1,18 @@
import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import {
attachInitTimeout,
createInitDeferred,
getInitDeferred,
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 10000;
const INIT_TIMEOUT_MS = 5 * 60_000;
export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
}
const deferred = createInitDeferred(key);
const timeoutId = setTimeout(() => {
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(
key,
new Error(
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
)
);
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
client
.initializeAgent(agentId)
.then(() => {
clearTimeout(timeoutId);
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
// message, handled in SessionContext.
})
.catch((error) => {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
return deferred.promise;
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
[client, serverId, setInitializingAgents]
);
const refreshAgent = useCallback(
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
try {
await client.refreshAgent(agentId);
} catch (error) {
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
throw error;
}
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
[client, serverId, setInitializingAgents]
);
return { ensureAgentIsInitialized, refreshAgent };
}
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
appVisibleRef.current = nextState === "active";
if (nextState === "active") {
recordUserActivity();
maybeSendImmediateHeartbeat();
}
// Send immediately on visibility changes so the server can adapt streaming behavior.
sendHeartbeat();
});
return () => subscription.remove();
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
}, [recordUserActivity, sendHeartbeat]);
// Track user activity on web for accurate staleness.
useEffect(() => {
+14
View File
@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
});
@@ -7,6 +7,7 @@ import {
ScrollView,
Platform,
BackHandler,
AppState,
} from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
@@ -31,6 +32,7 @@ import {
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
@@ -502,6 +504,46 @@ function AgentScreenContent({
});
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
// When the app comes back to the foreground, re-sync history for the focused agent.
// This covers cases where the OS/backgrounding caused us to miss stream events.
const lastAppStateRef = useRef(AppState.currentState);
const lastResumeSyncAtRef = useRef(0);
useEffect(() => {
if (!resolvedAgentId) {
return;
}
const subscription = AppState.addEventListener("change", (nextState) => {
const prev = lastAppStateRef.current;
lastAppStateRef.current = nextState;
if (nextState !== "active" || prev === "active") {
return;
}
if (!isConnected) {
return;
}
const now = Date.now();
// Avoid accidental double-syncs on rapid transitions.
if (now - lastResumeSyncAtRef.current < 2000) {
return;
}
lastResumeSyncAtRef.current = now;
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed on resume", {
agentId: resolvedAgentId,
error,
});
});
});
return () => {
subscription.remove();
};
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
@@ -569,7 +611,17 @@ function AgentScreenContent({
title={effectiveAgent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
<HeaderToggleButton
onPress={toggleFileExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
checkout?.isGit ? (
<GitBranch
@@ -600,7 +652,7 @@ function AgentScreenContent({
}
/>
)}
</Pressable>
</HeaderToggleButton>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
@@ -712,28 +764,21 @@ function AgentScreenContent({
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing && !shouldUseOptimisticStream ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
color={theme.colors.primary}
/>
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
/>
)}
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
/>
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
{agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
const deferred = createDeferred<any>();
const client = {
checkoutCommit: vi.fn(() => deferred.promise),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...(state.sessions as any),
[serverId]: { client } as any,
},
}));
const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, cwd });
const second = store.commit({ serverId, cwd });
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
expect(
store.getStatus({ serverId, cwd, actionId: "commit" })
).toBe("pending");
deferred.resolve({});
await Promise.all([first, second]);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
});
@@ -0,0 +1,283 @@
import { create } from "zustand";
import { useSessionStore } from "@/stores/session-store";
import { queryClient } from "@/query/query-client";
const SUCCESS_DISPLAY_MS = 1000;
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
export type CheckoutGitAsyncActionId =
| "commit"
| "push"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
return `${serverId}::${cwd}`;
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client;
}
function setStatus(
key: CheckoutKey,
actionId: CheckoutGitAsyncActionId,
status: CheckoutGitActionStatus
) {
useCheckoutGitActionsStore.setState((state) => {
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
if (current === status) {
return state;
}
return {
...state,
statusByCheckout: {
...state.statusByCheckout,
[key]: {
...(state.statusByCheckout[key] ?? {}),
[actionId]: status,
},
},
};
});
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
void queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutDiff" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
}
function invalidateWorktreeList() {
void queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
return `${key}::${actionId}`;
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
getStatus: (params: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
async function runCheckoutAction({
serverId,
cwd,
actionId,
run,
}: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
const key = checkoutKey(serverId, cwd);
const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId);
if (existing) {
await existing;
return;
}
const prevTimer = successTimers.get(inflightId);
if (prevTimer) {
clearTimeout(prevTimer);
successTimers.delete(inflightId);
}
setStatus(key, actionId, "pending");
const promise = (async () => {
try {
await run();
invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
successTimers.delete(inflightId);
}, SUCCESS_DISPLAY_MS);
successTimers.set(inflightId, timer);
} catch (error) {
setStatus(key, actionId, "idle");
throw error;
} finally {
inFlight.delete(inflightId);
}
})();
inFlight.set(inflightId, promise);
await promise;
}
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {},
getStatus: ({ serverId, cwd, actionId }) => {
const key = checkoutKey(serverId, cwd);
return get().statusByCheckout[key]?.[actionId] ?? "idle";
},
commit: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "commit",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "push",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "create-pr",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-branch",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-from-base",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
invalidateWorktreeList();
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
for (const timer of successTimers.values()) {
clearTimeout(timer);
}
successTimers.clear();
inFlight.clear();
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
}
+5 -19
View File
@@ -46,25 +46,11 @@ function createUniqueTimelineId(
timestamp: Date
): string {
const base = createTimelineId(prefix, text, timestamp);
let suffixSeed = state.length;
let candidate = `${base}_${suffixSeed.toString(36)}`;
// Fast path when the generated id hasn't been used yet
const hasCollision = state.some((entry) => entry.id === candidate);
if (!hasCollision) {
return candidate;
}
// If the id already exists (e.g. prior items were pruned/replaced),
// spin until we find an unused suffix. Building the lookup Set lazily keeps
// the common case cheap while still guaranteeing uniqueness.
const usedIds = new Set(state.map((entry) => entry.id));
while (usedIds.has(candidate)) {
suffixSeed += 1;
candidate = `${base}_${suffixSeed.toString(36)}`;
}
return candidate;
// We only ever append new timeline items, and we incorporate the current
// length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
// collision scan (important for large hydration snapshots).
const suffixSeed = state.length;
return `${base}_${suffixSeed.toString(36)}`;
}
export type StreamItem =
+22 -3
View File
@@ -2,6 +2,7 @@ export interface DeferredInit {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
const initPromises = new Map<string, DeferredInit>();
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
reject = rej;
});
const deferred: DeferredInit = { promise, resolve, reject };
const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
initPromises.set(key, deferred);
return deferred;
}
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
const deferred = initPromises.get(key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
deferred.timeoutId = timeoutId;
}
export function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key);
deferred?.resolve();
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.resolve();
}
export function rejectInitDeferred(key: string, error: Error): void {
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.reject(error);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatShortcut } from "./format-shortcut";
describe("formatShortcut", () => {
it("uses symbols on macOS", () => {
expect(formatShortcut(["mod", "B"], "mac")).toBe("⌘B");
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
});
it("uses Ctrl+ on non-mac platforms", () => {
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
});
});
+53
View File
@@ -0,0 +1,53 @@
export type ShortcutKey =
| "mod"
| "shift"
| "alt"
| "ctrl"
| "meta"
| string;
export type ShortcutOs = "mac" | "non-mac";
function normalizeKey(key: string): string {
if (!key) return "";
if (key.length === 1) return key.toUpperCase();
return key;
}
export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
const normalized = keys.map((k) => (typeof k === "string" ? k : String(k)));
if (os === "mac") {
const order = ["ctrl", "alt", "shift", "mod", "meta"];
const symbols: Record<string, string> = {
mod: "⌘",
shift: "⇧",
alt: "⌥",
ctrl: "⌃",
meta: "⌘",
};
const modifierSet = new Set(normalized);
const mods = order
.filter((k) => modifierSet.has(k))
.map((k) => symbols[k] ?? "");
const main = normalized
.filter((k) => !order.includes(k))
.map(normalizeKey)
.join("");
return `${mods.join("")}${main}`;
}
const labels: Record<string, string> = {
mod: "Ctrl",
shift: "Shift",
alt: "Alt",
ctrl: "Ctrl",
meta: "Win",
};
return normalized
.map((k) => labels[k] ?? normalizeKey(k))
.filter(Boolean)
.join("+");
}
@@ -0,0 +1,18 @@
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsTauriMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
const isApple =
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
/Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
}
+34 -33
View File
@@ -1,39 +1,23 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import { connectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
/** Model list item for display */
export interface ModelListItem {
model: string
id: string
description: string
}
/** Static model data by provider */
const MODELS_BY_PROVIDER: Record<string, ModelListItem[]> = {
claude: [
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
],
codex: [
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
opencode: [
// opencode uses claude or codex under the hood
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
}
const VALID_PROVIDERS = ['claude', 'codex', 'opencode']
/** Schema for provider models output */
export const providerModelsSchema: OutputSchema<ModelListItem> = {
idField: 'id',
columns: [
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'ID', field: 'id', width: 30 },
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
],
}
@@ -45,25 +29,42 @@ export interface ProviderModelsOptions extends CommandOptions {
export async function runModelsCommand(
provider: string,
_options: ProviderModelsOptions,
options: ProviderModelsOptions,
_command: Command
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase()
const models = MODELS_BY_PROVIDER[normalizedProvider]
if (!models) {
const validProviders = Object.keys(MODELS_BY_PROVIDER).join(', ')
const error: CommandError = {
if (!VALID_PROVIDERS.includes(normalizedProvider)) {
throw {
code: 'UNKNOWN_PROVIDER',
message: `Unknown provider: ${provider}`,
details: `Valid providers: ${validProviders}`,
details: `Valid providers: ${VALID_PROVIDERS.join(', ')}`,
}
throw error
}
return {
type: 'list',
data: models,
schema: providerModelsSchema,
const client = await connectToDaemon({ host: options.host })
try {
const result = await client.listProviderModels(normalizedProvider)
if (result.error) {
throw {
code: 'PROVIDER_ERROR',
message: `Failed to fetch models for ${provider}: ${result.error}`,
}
}
const models: ModelListItem[] = (result.models ?? []).map((m) => ({
model: m.label,
id: m.id,
description: m.description ?? '',
}))
return {
type: 'list',
data: models,
schema: providerModelsSchema,
}
} finally {
await client.close()
}
}
+1
View File
@@ -7,3 +7,4 @@ dist
conversations
.cache
.debug/
.env.test
@@ -494,4 +494,125 @@ describe("AgentManager", () => {
const updatedAgent = manager.getAgent(snapshot.id);
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
});
test("close during in-flight stream does not clear persistence sessionId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CloseRaceSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private threadId: string | null = this.id;
private releaseStream: (() => void) | null = null;
private closed = false;
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
if (!this.closed) {
await new Promise<void>((resolve) => {
this.releaseStream = resolve;
});
}
yield { type: "turn_canceled", provider: this.provider, reason: "closed" };
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.threadId,
model: null,
modeId: null,
};
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
if (!this.threadId) {
return null;
}
return { provider: this.provider, sessionId: this.threadId };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
this.threadId = null;
this.releaseStream?.();
}
}
class CloseRaceClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
return new CloseRaceSession();
}
async resumeSession(): Promise<AgentSession> {
return new CloseRaceSession();
}
}
const manager = new AgentManager({
clients: {
codex: new CloseRaceClient(),
},
registry: storage,
logger,
idFactory: () => "close-race-agent",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const stream = manager.streamAgent(snapshot.id, "hello");
await stream.next();
await manager.closeAgent(snapshot.id);
// Drain stream finalizer path after close().
while (true) {
const next = await stream.next();
if (next.done) {
break;
}
}
await manager.flush();
await storage.flush();
const persisted = await storage.get(snapshot.id);
expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId);
});
});
@@ -601,13 +601,17 @@ export class AgentManager {
mutableAgent.pendingRun = null;
mutableAgent.lifecycle = error ? "error" : "idle";
mutableAgent.lastError = error;
mutableAgent.persistence = attachPersistenceCwd(
const persistenceHandle =
mutableAgent.session.describePersistence() ??
(mutableAgent.runtimeInfo?.sessionId
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null),
mutableAgent.cwd
);
(mutableAgent.runtimeInfo?.sessionId
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null);
if (persistenceHandle) {
mutableAgent.persistence = attachPersistenceCwd(
persistenceHandle,
mutableAgent.cwd
);
}
this.emitState(mutableAgent);
};
@@ -1113,7 +1117,12 @@ export class AgentManager {
case "thread_started":
// Update persistence with the new session ID from the provider.
// persistence.sessionId is the single source of truth for session identity.
agent.persistence = attachPersistenceCwd(agent.session.describePersistence(), agent.cwd);
{
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
}
}
break;
case "timeline":
this.recordTimeline(agent, event.item);
@@ -113,6 +113,7 @@ type ClaudeAgentClientOptions = {
type ClaudeAgentSessionOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
claudePath: string | null;
handle?: AgentPersistenceHandle;
logger: Logger;
};
@@ -315,16 +316,23 @@ export class ClaudeAgentClient implements AgentClient {
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
private readonly claudePath: string | null;
constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
try {
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
} catch {
this.claudePath = null;
}
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
claudePath: this.claudePath,
logger: this.logger,
});
}
@@ -342,6 +350,7 @@ export class ClaudeAgentClient implements AgentClient {
const claudeConfig = this.assertConfig(mergedConfig);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
claudePath: this.claudePath,
handle,
logger: this.logger,
});
@@ -353,6 +362,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: options?.cwd ?? process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
};
const claudeQuery = query({ prompt, options: claudeOptions });
@@ -407,12 +417,7 @@ export class ClaudeAgentClient implements AgentClient {
}
async isAvailable(): Promise<boolean> {
try {
const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
return Boolean(claudePath);
} catch {
return false;
}
return this.claudePath !== null;
}
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
@@ -429,6 +434,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly claudePath: string | null;
private readonly logger: Logger;
private query: Query | null = null;
private input: Pushable<SDKUserMessage> | null = null;
@@ -464,6 +470,7 @@ class ClaudeAgentSession implements AgentSession {
) {
this.config = config;
this.defaults = options.defaults;
this.claudePath = options.claudePath;
this.logger = options.logger;
const handle = options.handle;
@@ -880,6 +887,7 @@ class ClaudeAgentSession implements AgentSession {
permissionMode: this.currentMode,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append orchestrator mode instructions for agents
systemPrompt: {
@@ -158,7 +158,6 @@ describe("Codex app-server provider (integration)", () => {
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
await session.run("Reply with OK.");
const info = await session.getRuntimeInfo();
await session.close();
@@ -730,9 +729,20 @@ describe("Codex app-server provider (integration)", () => {
if (captured) {
expect(sawPermissionResolved).toBe(true);
}
const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 5000 });
expect(timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"))).toBe(true);
expect(text?.trim()).toBe("ok");
const sawPatch = timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"));
expect(sawPatch).toBe(true);
const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 10000 });
if (!text) {
const toolNames = timelineItems
.filter((item) => item.type === "tool_call")
.map((item) => item.name)
.join(", ");
throw new Error(
`approval-test.txt was not written after file change approval flow (saw tools: ${toolNames || "none"})`
);
}
expect(text.trim()).toBe("ok");
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
@@ -33,9 +33,12 @@ import { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { z } from "zod";
import { loadCodexPersistedTimeline } from "./codex-mcp-agent.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
@@ -676,12 +679,26 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
}));
}
function normalizeCodexFilePath(filePath: unknown, cwd: string | null | undefined): string | null {
if (typeof filePath !== "string") return null;
const trimmed = filePath.trim();
if (!trimmed) return null;
if (typeof cwd === "string" && cwd.trim().length > 0) {
const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`;
if (trimmed.startsWith(normalizedCwd)) {
return trimmed.slice(normalizedCwd.length);
}
}
return trimmed;
}
function threadItemToTimeline(
item: any,
options?: { includeUserMessage?: boolean }
options?: { includeUserMessage?: boolean; cwd?: string | null }
): AgentTimelineItem | null {
if (!item || typeof item !== "object") return null;
const includeUserMessage = options?.includeUserMessage ?? true;
const cwd = options?.cwd ?? null;
switch (item.type) {
case "userMessage": {
if (!includeUserMessage) {
@@ -722,14 +739,17 @@ function threadItemToTimeline(
case "fileChange": {
const files = Array.isArray(item.changes)
? item.changes.map((change: any) => ({
path: change.path,
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
kind: change.kind,
}))
: [];
const outputFiles = Array.isArray(item.changes)
? item.changes.map((change: any) => ({
path: change.path,
patch: change.diff,
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
patch:
typeof change.diff === "string"
? truncateUtf8Bytes(change.diff, MAX_FILE_PATCH_BYTES).text
: change.diff,
kind: change.kind,
}))
: [];
@@ -823,6 +843,223 @@ function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
return { mimeType, data };
}
function truncateUtf8Bytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (maxBytes <= 0) {
return { text: "", truncated: text.length > 0 };
}
const bytes = Buffer.byteLength(text, "utf8");
if (bytes <= maxBytes) {
return { text, truncated: false };
}
const buffer = Buffer.from(text, "utf8");
const sliced = buffer.subarray(0, maxBytes);
return { text: sliced.toString("utf8"), truncated: true };
}
const MAX_FILE_PATCH_BYTES = 128 * 1024;
const ThreadStartedNotificationSchema = z.object({
thread: z.object({ id: z.string() }).passthrough(),
}).passthrough();
const TurnStartedNotificationSchema = z.object({
turn: z.object({ id: z.string() }).passthrough(),
}).passthrough();
const TurnCompletedNotificationSchema = z.object({
turn: z
.object({
status: z.string(),
error: z
.object({
message: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough(),
}).passthrough();
const TurnPlanUpdatedNotificationSchema = z.object({
plan: z.array(
z
.object({
step: z.string().optional(),
status: z.string().optional(),
})
.passthrough()
),
}).passthrough();
const TurnDiffUpdatedNotificationSchema = z.object({
diff: z.string(),
}).passthrough();
const ThreadTokenUsageUpdatedNotificationSchema = z.object({
tokenUsage: z.unknown(),
}).passthrough();
const ItemTextDeltaNotificationSchema = z.object({
itemId: z.string(),
delta: z.string(),
}).passthrough();
const ItemLifecycleNotificationSchema = z.object({
item: z
.object({
id: z.string().optional(),
type: z.string().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventTurnAbortedNotificationSchema = z.object({
msg: z
.object({
type: z.literal("turn_aborted"),
reason: z.string().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventTaskCompleteNotificationSchema = z.object({
msg: z
.object({
type: z.literal("task_complete"),
})
.passthrough(),
}).passthrough();
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string }
| { kind: "turn_completed"; status: string; errorMessage: string | null }
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
| { kind: "diff_updated"; diff: string }
| { kind: "token_usage_updated"; tokenUsage: unknown }
| { kind: "agent_message_delta"; itemId: string; delta: string }
| { kind: "reasoning_delta"; itemId: string; delta: string }
| { kind: "item_completed"; item: { id?: string; type?: string; [key: string]: unknown } }
| { kind: "item_started"; item: { id?: string; type?: string; [key: string]: unknown } }
| { kind: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; method: string; params: unknown };
const CodexNotificationSchema = z.union([
z.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "thread_started", threadId: params.thread.id })
),
z.object({ method: z.literal("thread/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "turn_started", turnId: params.turn.id })
),
z.object({ method: z.literal("turn/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("turn/completed"), params: TurnCompletedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_completed",
status: params.turn.status,
errorMessage: params.turn.error?.message ?? null,
})
),
z.object({ method: z.literal("turn/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("turn/plan/updated"), params: TurnPlanUpdatedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "plan_updated",
plan: params.plan.map((entry) => ({
step: entry.step ?? null,
status: entry.status ?? null,
})),
})
),
z.object({ method: z.literal("turn/plan/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("turn/diff/updated"), params: TurnDiffUpdatedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "diff_updated", diff: params.diff })
),
z.object({ method: z.literal("turn/diff/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("thread/tokenUsage/updated"),
params: ThreadTokenUsageUpdatedNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({ kind: "token_usage_updated", tokenUsage: params.tokenUsage })
),
z.object({ method: z.literal("thread/tokenUsage/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("item/agentMessage/delta"), params: ItemTextDeltaNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "agent_message_delta",
itemId: params.itemId,
delta: params.delta,
})
),
z.object({ method: z.literal("item/agentMessage/delta"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("item/reasoning/summaryTextDelta"),
params: ItemTextDeltaNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "reasoning_delta",
itemId: params.itemId,
delta: params.delta,
})
),
z.object({ method: z.literal("item/reasoning/summaryTextDelta"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "item_completed", item: params.item })
),
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "item_started", item: params.item })
),
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/turn_aborted"),
params: CodexEventTurnAbortedNotificationSchema,
}).transform(
(): ParsedCodexNotification => ({
kind: "turn_completed",
status: "interrupted",
errorMessage: null,
})
),
z.object({ method: z.literal("codex/event/turn_aborted"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/task_complete"),
params: CodexEventTaskCompleteNotificationSchema,
}).transform(
(): ParsedCodexNotification => ({
kind: "turn_completed",
status: "completed",
errorMessage: null,
})
),
z.object({ method: z.literal("codex/event/task_complete"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.string(), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "unknown_method", method, params })
),
]);
async function writeImageAttachment(mimeType: string, data: string): Promise<string> {
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
await fs.mkdir(attachmentsDir, { recursive: true });
@@ -899,6 +1136,8 @@ class CodexAppServerAgentSession implements AgentSession {
private resolvedPermissionRequests = new Set<string>();
private pendingAgentMessages = new Map<string, string>();
private pendingReasoning = new Map<string, string[]>();
private warnedUnknownNotificationMethods = new Set<string>();
private warnedInvalidNotificationPayloads = new Set<string>();
private latestUsage: AgentUsage | undefined;
private connected = false;
private collaborationModes: Array<{
@@ -1054,22 +1293,40 @@ class CodexAppServerAgentSession implements AgentSession {
private async loadPersistedHistory(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
try {
let rolloutTimeline: AgentTimelineItem[] = [];
try {
rolloutTimeline = await loadCodexPersistedTimeline(
this.currentThreadId,
undefined,
this.logger
);
} catch {
rolloutTimeline = [];
}
const response = (await this.client.request("thread/read", {
threadId: this.currentThreadId,
includeTurns: true,
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
const thread = response?.thread;
if (!thread || !Array.isArray(thread.turns)) return;
const timeline: AgentTimelineItem[] = [];
for (const turn of thread.turns) {
const items = Array.isArray(turn.items) ? turn.items : [];
for (const item of items) {
const timelineItem = threadItemToTimeline(item);
if (timelineItem) {
timeline.push(timelineItem);
const threadTimeline: AgentTimelineItem[] = [];
if (thread && Array.isArray(thread.turns)) {
for (const turn of thread.turns) {
const items = Array.isArray(turn.items) ? turn.items : [];
for (const item of items) {
const timelineItem = threadItemToTimeline(item, {
cwd: this.config.cwd ?? null,
});
if (timelineItem) {
threadTimeline.push(timelineItem);
}
}
}
}
const timeline =
rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
if (timeline.length > 0) {
this.persistedHistory = timeline;
this.historyPending = true;
@@ -1176,7 +1433,7 @@ class CodexAppServerAgentSession implements AgentSession {
params.outputSchema = options.outputSchema;
}
await this.client.request("turn/start", params);
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
for await (const event of queue) {
yield event;
@@ -1209,6 +1466,12 @@ class CodexAppServerAgentSession implements AgentSession {
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
if (!this.connected) {
await this.connect();
}
if (!this.currentThreadId) {
await this.ensureThread();
}
const info: AgentRuntimeInfo = {
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
@@ -1479,125 +1742,141 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void {
this.emitEvent({ type: "provider_event", provider: CODEX_PROVIDER, raw: { method, params } });
const parsed = CodexNotificationSchema.parse({ method, params });
switch (method) {
case "thread/started": {
const threadId = (params as any)?.thread?.id;
if (threadId) {
this.currentThreadId = threadId;
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: threadId });
}
break;
}
case "turn/started": {
const turnId = (params as any)?.turn?.id;
if (turnId) {
this.currentTurnId = turnId;
}
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
break;
}
case "turn/completed": {
const turn = (params as any)?.turn;
const status = turn?.status;
if (status === "failed") {
const message = turn?.error?.message ?? "Codex turn failed";
this.emitEvent({ type: "turn_failed", provider: CODEX_PROVIDER, error: message });
} else if (status === "interrupted") {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
}
this.eventQueue?.end();
break;
}
case "turn/plan/updated": {
const steps = (params as any)?.plan;
if (Array.isArray(steps)) {
const items = planStepsToTodoItems(
steps.map((entry: any) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
}))
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "todo", items },
});
}
break;
}
case "turn/diff/updated": {
const diff = (params as any)?.diff;
if (typeof diff === "string" && diff.trim().length > 0) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({
name: "apply_patch",
status: "running",
output: { diff },
}),
});
}
break;
}
case "thread/tokenUsage/updated": {
const tokenUsage = (params as any)?.tokenUsage;
this.latestUsage = toAgentUsage(tokenUsage);
break;
}
case "item/agentMessage/delta": {
const itemId = (params as any)?.itemId;
const delta = (params as any)?.delta;
if (itemId && typeof delta === "string") {
const prev = this.pendingAgentMessages.get(itemId) ?? "";
this.pendingAgentMessages.set(itemId, prev + delta);
}
break;
}
case "item/reasoning/summaryTextDelta": {
const itemId = (params as any)?.itemId;
const delta = (params as any)?.delta;
if (itemId && typeof delta === "string") {
const prev = this.pendingReasoning.get(itemId) ?? [];
prev.push(delta);
this.pendingReasoning.set(itemId, prev);
}
break;
}
case "item/completed": {
const item = (params as any)?.item;
const timelineItem = threadItemToTimeline(item, { includeUserMessage: false });
if (timelineItem) {
if (timelineItem.type === "assistant_message" && item?.id) {
const buffered = this.pendingAgentMessages.get(item.id);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered;
}
}
if (timelineItem.type === "reasoning" && item?.id) {
const buffered = this.pendingReasoning.get(item.id);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered.join("");
}
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
break;
}
case "item/started": {
const item = (params as any)?.item;
const timelineItem = threadItemToTimeline(item, { includeUserMessage: false });
if (timelineItem && timelineItem.type === "tool_call") {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
break;
}
default:
break;
if (parsed.kind === "thread_started") {
this.currentThreadId = parsed.threadId;
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: parsed.threadId });
return;
}
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
return;
}
if (parsed.kind === "turn_completed") {
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: parsed.errorMessage ?? "Codex turn failed",
});
} else if (parsed.status === "interrupted") {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
}
this.eventQueue?.end();
return;
}
if (parsed.kind === "plan_updated") {
const items = planStepsToTodoItems(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
}))
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "todo", items },
});
return;
}
if (parsed.kind === "diff_updated") {
if (parsed.diff.trim().length > 0) {
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry.
// We intentionally do NOT store it in the agent timeline to avoid
// snapshot bloat and relay/WebSocket size limits.
}
return;
}
if (parsed.kind === "token_usage_updated") {
this.latestUsage = toAgentUsage(parsed.tokenUsage);
return;
}
if (parsed.kind === "agent_message_delta") {
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
return;
}
if (parsed.kind === "reasoning_delta") {
const prev = this.pendingReasoning.get(parsed.itemId) ?? [];
prev.push(parsed.delta);
this.pendingReasoning.set(parsed.itemId, prev);
return;
}
if (parsed.kind === "item_completed") {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (timelineItem) {
const itemId = parsed.item.id;
if (timelineItem.type === "assistant_message" && itemId) {
const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered;
}
}
if (timelineItem.type === "reasoning" && itemId) {
const buffered = this.pendingReasoning.get(itemId);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered.join("");
}
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
if (parsed.kind === "item_started") {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (timelineItem && timelineItem.type === "tool_call") {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
if (parsed.kind === "invalid_payload") {
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
return;
}
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
}
private warnUnknownNotificationMethod(method: string, params: unknown): void {
if (this.warnedUnknownNotificationMethods.has(method)) {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.warn({ method, params }, "Unhandled Codex app-server notification method");
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {
const key = method;
if (this.warnedInvalidNotificationPayloads.has(key)) {
return;
}
this.warnedInvalidNotificationPayloads.add(key);
this.logger.warn(
{ method, params },
"Invalid Codex app-server notification payload"
);
}
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
@@ -1741,19 +2020,27 @@ export class CodexAppServerAgentClient implements AgentClient {
const title = thread.preview ?? null;
let timeline: AgentTimelineItem[] = [];
try {
const rolloutTimeline = await loadCodexPersistedTimeline(
threadId,
undefined,
this.logger
);
const read = (await client.request("thread/read", {
threadId,
includeTurns: true,
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
const turns = read.thread?.turns ?? [];
const items: AgentTimelineItem[] = [];
const itemsFromThreadRead: AgentTimelineItem[] = [];
for (const turn of turns) {
for (const item of turn.items ?? []) {
const timelineItem = threadItemToTimeline(item);
if (timelineItem) items.push(timelineItem);
const timelineItem = threadItemToTimeline(item, { cwd });
if (timelineItem) itemsFromThreadRead.push(timelineItem);
}
}
timeline = items;
timeline =
rolloutTimeline.length > 0
? rolloutTimeline
: itemsFromThreadRead;
} catch {
timeline = [];
}
@@ -5097,10 +5097,20 @@ async function findRolloutFile(
return null;
}
const RolloutContentItemSchema = z.object({
type: z.string(),
text: z.string(),
}).passthrough();
const RolloutContentItemSchema = z
.union([
z.object({ type: z.literal("input_text"), text: z.string() }),
z.object({ type: z.literal("output_text"), text: z.string() }),
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
z.object({ type: z.literal("text"), text: z.string() }),
z
.object({
type: z.string(),
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough(),
]);
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
@@ -5116,77 +5126,131 @@ function extractContentTextByType(content: unknown, itemType: string): string {
.trim();
}
const RolloutResponsePayloadSchema = z.object({
type: z.string().optional(),
role: z.string().optional(),
const RolloutResponseMessagePayloadSchema = z.object({
type: z.literal("message"),
role: z.enum(["user", "assistant"]).optional(),
content: z.unknown().optional(),
});
const RolloutResponseReasoningPayloadSchema = z.object({
type: z.literal("reasoning"),
content: z.unknown().optional(),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.string().optional(),
output: z.string().optional(),
summary: z.array(z.object({ text: z.string().optional() })).optional(),
text: z.string().optional(),
});
const RolloutEventPayloadSchema = z.object({
type: z.string().optional(),
const RolloutResponseFunctionCallPayloadSchema = z.object({
type: z.literal("function_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.string().optional(),
});
const RolloutResponseCustomToolCallPayloadSchema = z.object({
type: z.literal("custom_tool_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.string().optional(),
});
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({
type: z.literal("function_call_output"),
call_id: z.string().optional(),
output: z.string().optional(),
});
const RolloutEventAgentReasoningPayloadSchema = z.object({
type: z.literal("agent_reasoning"),
text: z.string().optional(),
message: z.string().optional(),
});
const RolloutEntrySchema = z.object({
type: z.enum(["response_item", "event_msg"]),
payload: z.unknown().optional(),
const RolloutEventAgentMessagePayloadSchema = z.object({
type: z.literal("agent_message"),
message: z
.union([
z.string(),
z
.object({
role: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough(),
])
.optional(),
});
type RolloutEntry = z.infer<typeof RolloutEntrySchema>;
type RolloutResponsePayload = z.infer<typeof RolloutResponsePayloadSchema>;
const RolloutEventUserMessagePayloadSchema = z.object({
type: z.literal("user_message"),
message: z
.union([
z.string(),
z
.object({
role: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough(),
])
.optional(),
});
function parseRolloutEntryFromLine(line: string): RolloutEntry | null {
if (!line) {
return null;
}
try {
const parsed = JSON.parse(line);
const result = RolloutEntrySchema.safeParse(parsed);
if (result.success) {
return result.data;
}
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as { output?: unknown }).output === "string"
) {
return parseRolloutEntryFromLine((parsed as { output: string }).output);
}
} catch {
return null;
}
return null;
}
type RolloutResponseReasoningPayload = z.infer<
typeof RolloutResponseReasoningPayloadSchema
>;
type ParsedRolloutRecord =
| { kind: "timeline"; item: AgentTimelineItem }
| { kind: "call"; name: string; callId?: string; input?: unknown }
| { kind: "output"; callId: string; output: string }
| { kind: "ignore" };
const RolloutMessageContentSchema = z
.union([
z.string().transform((content) => content.trim()),
z.array(
z
.object({
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough()
).transform((content) =>
content
.map((block) => block.text ?? block.message ?? "")
.map((text) => text.trim())
.filter(Boolean)
.join("\n")
.trim()
),
]);
function extractMessageText(content: unknown): string {
if (!Array.isArray(content)) {
const parsed = RolloutMessageContentSchema.safeParse(content);
if (!parsed.success) {
return "";
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const record = block as Record<string, unknown>;
const text = typeof record.text === "string" ? record.text : undefined;
if (text && text.trim()) {
parts.push(text.trim());
continue;
}
const message =
typeof record.message === "string" ? record.message : undefined;
if (message && message.trim()) {
parts.push(message.trim());
}
return parsed.data;
}
const RolloutEventMessageTextSchema = z
.union([
z.string(),
z
.object({
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough()
.transform((message) => message.message ?? message.text ?? ""),
]);
function extractEventMessageText(message: unknown): string {
const parsed = RolloutEventMessageTextSchema.safeParse(message);
if (!parsed.success) {
return "";
}
return parts.join("\n").trim();
return parsed.data;
}
function isSyntheticRolloutUserMessage(text: string): boolean {
@@ -5207,10 +5271,10 @@ function isSyntheticRolloutUserMessage(text: string): boolean {
return false;
}
function extractReasoningText(payload: RolloutResponsePayload): string {
if (Array.isArray(payload?.summary)) {
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
if (Array.isArray(payload.summary)) {
const text = payload.summary
.map((item) => (item && typeof item.text === "string" ? item.text : ""))
.map((item) => item.text ?? "")
.filter(Boolean)
.join("\n")
.trim();
@@ -5218,17 +5282,117 @@ function extractReasoningText(payload: RolloutResponsePayload): string {
return text;
}
}
// Handle content array with reasoning_text items
const contentText = extractContentTextByType(payload.content, "reasoning_text");
if (contentText) {
return contentText;
}
if (typeof payload?.text === "string") {
if (typeof payload.text === "string") {
return payload.text;
}
return "";
}
function parseJsonLikeString(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
const FunctionCallInputNormalizationSchema = z
.union([
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
z
.object({ command: z.array(z.string()) })
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
z.unknown().transform((input) => ({ name: "unknown", input })),
]);
const RolloutResponseRecordSchema = z
.union([
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractMessageText(payload.content);
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
const shouldEmit = text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
return shouldEmit
? { kind: "timeline", item: { type: itemType, text } }
: { kind: "ignore" };
}),
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractReasoningText(payload);
return text.length > 0
? { kind: "timeline", item: { type: "reasoning", text } }
: { kind: "ignore" };
}),
z
.union([
RolloutResponseFunctionCallPayloadSchema,
RolloutResponseCustomToolCallPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown";
const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined;
const normalized =
rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
: { name: rawName, input: parsedArguments };
const skip = rawName === "write_stdin";
return skip
? { kind: "ignore" }
: {
kind: "call",
name: normalized.name,
callId: payload.call_id,
input: normalized.input,
};
}),
RolloutResponseFunctionCallOutputPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.call_id && payload.output
? { kind: "output", callId: payload.call_id, output: payload.output }
: { kind: "ignore" }
),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutEventRecordSchema = z
.union([
RolloutEventAgentReasoningPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.text
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
: { kind: "ignore" }
),
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
return text.length > 0
? { kind: "timeline", item: { type: "assistant_message", text } }
: { kind: "ignore" };
}),
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
return shouldEmit
? { kind: "timeline", item: { type: "user_message", text } }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutRecordSchema = z
.object({
type: z.enum(["response_item", "event_msg"]),
payload: z.unknown().optional(),
item: z.unknown().optional(),
msg: z.unknown().optional(),
})
.transform((entry) =>
entry.type === "response_item"
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg)
);
function parseJsonRolloutTimeline(
parsed: unknown
): AgentTimelineItem[] | null {
@@ -5244,25 +5408,28 @@ function parseJsonRolloutTimeline(
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
const type = record.type;
if (type === "message") {
const role = record.role;
const text = extractMessageText(record.content);
if (!text || typeof role !== "string") {
const messagePayloadResult =
RolloutResponseMessagePayloadSchema.safeParse(entry);
if (messagePayloadResult.success) {
const payload = messagePayloadResult.data;
const text = extractMessageText(payload.content);
if (!text) {
continue;
}
if (role === "assistant") {
if (payload.role === "assistant") {
timeline.push({ type: "assistant_message", text });
} else if (role === "user") {
} else if (payload.role === "user") {
if (!isSyntheticRolloutUserMessage(text)) {
timeline.push({ type: "user_message", text });
}
}
continue;
}
if (type === "reasoning") {
const text = extractReasoningText(record as RolloutResponsePayload);
const reasoningPayloadResult =
RolloutResponseReasoningPayloadSchema.safeParse(entry);
if (reasoningPayloadResult.success) {
const text = extractReasoningText(reasoningPayloadResult.data);
if (text) {
timeline.push({ type: "reasoning", text });
}
@@ -5294,126 +5461,39 @@ export async function parseRolloutFile(
.map((line) => line.trim())
.filter(Boolean);
// First pass: collect function_call_output entries by call_id
const outputsByCallId = new Map<string, string>();
for (const line of lines) {
const entry = parseRolloutEntryFromLine(line);
if (!entry || entry.type !== "response_item") continue;
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
if (!payloadResult.success) continue;
const payload = payloadResult.data;
if (payload.type === "function_call_output" && payload.call_id && payload.output) {
outputsByCallId.set(payload.call_id, payload.output);
}
}
// Second pass: build timeline
const timeline: AgentTimelineItem[] = [];
for (const line of lines) {
const entry = parseRolloutEntryFromLine(line);
if (!entry) continue;
if (entry.type === "response_item") {
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
if (!payloadResult.success) continue;
const payload = payloadResult.data;
switch (payload.type) {
case "message": {
const text = extractMessageText(payload.content);
if (text) {
if (payload.role === "assistant") {
timeline.push({ type: "assistant_message", text });
} else if (payload.role === "user") {
if (!isSyntheticRolloutUserMessage(text)) {
timeline.push({ type: "user_message", text });
}
}
}
break;
}
case "reasoning": {
const text = extractReasoningText(payload);
if (text) {
timeline.push({ type: "reasoning", text });
}
break;
}
case "function_call":
case "custom_tool_call": {
const rawName = payload.name ?? "unknown";
const callId = payload.call_id;
// Skip internal polling calls
if (rawName === "write_stdin") {
break;
}
let input: unknown;
if (payload.arguments) {
try {
input = JSON.parse(payload.arguments);
} catch {
input = payload.arguments;
}
}
// Map exec_command and shell to Bash with normalized input
let name = rawName;
if (rawName === "exec_command" && input && typeof input === "object") {
const execInput = input as { cmd?: string };
if (execInput.cmd) {
name = "Bash";
input = { command: execInput.cmd };
}
} else if (rawName === "shell" && input && typeof input === "object") {
// Older format: { command: ["bash", "-lc", "actual cmd"], workdir: "..." }
const shellInput = input as { command?: string[] };
if (Array.isArray(shellInput.command) && shellInput.command.length >= 3) {
name = "Bash";
// command[2] is the actual shell command after "bash -lc"
input = { command: shellInput.command[2] };
}
}
// Attach output if available
const output = callId ? outputsByCallId.get(callId) : undefined;
timeline.push({
type: "tool_call",
name,
callId,
status: "completed",
input,
...(output ? { output } : {}),
});
break;
}
case "function_call_output":
// Already processed in first pass
break;
default:
break;
const parsedRecords = lines
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
} else if (entry.type === "event_msg") {
const payloadResult = RolloutEventPayloadSchema.safeParse(entry.payload);
if (!payloadResult.success) continue;
const payload = payloadResult.data;
})
.filter((record): record is unknown => record !== null)
.map((record) => RolloutRecordSchema.safeParse(record))
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
.map((result) => result.data);
if (payload.type === "agent_reasoning" && payload.text) {
timeline.push({ type: "reasoning", text: payload.text });
} else if (payload.type === "agent_message" && payload.message) {
timeline.push({ type: "assistant_message", text: payload.message });
} else if (payload.type === "user_message" && payload.message) {
if (!isSyntheticRolloutUserMessage(payload.message)) {
timeline.push({ type: "user_message", text: payload.message });
}
}
}
}
const outputsByCallId = parsedRecords
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>());
return timeline;
return parsedRecords.flatMap((record): AgentTimelineItem[] =>
record.kind === "timeline"
? [record.item]
: record.kind === "call"
? [
{
type: "tool_call",
name: record.name,
callId: record.callId,
status: "completed",
input: record.input,
output: record.callId ? outputsByCallId.get(record.callId) : undefined,
},
]
: []
);
}
type CodexPersistedTimelineOptions = {
@@ -216,6 +216,79 @@ describe("codex rollout parsing", () => {
text: "Let me think about this.",
});
});
test("parses legacy response_item shape using item", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
item: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"echo hello"}',
call_id: "call_legacy_1",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:09:01.785Z",
type: "response_item",
item: {
type: "function_call_output",
call_id: "call_legacy_1",
output: "hello",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
callId: "call_legacy_1",
input: { command: "echo hello" },
output: "hello",
});
});
test("parses legacy event_msg shape using msg", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "agent_reasoning",
text: "thinking",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "agent_message",
message: { role: "assistant", message: "done" },
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "user_message",
message: { role: "user", message: "question" },
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" });
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
});
});
describe("complex conversation", () => {
+1
View File
@@ -33,6 +33,7 @@ async function main() {
if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false;
}
const daemon = await createPaseoDaemon(config, logger);
try {
+18
View File
@@ -585,6 +585,24 @@ export class Session {
return;
}
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
// while the app is visible and the user is focused on that agent.
//
// History catch-up is handled via explicit `initialize_agent_request` which emits a
// batched `agent_stream_snapshot`.
const activity = this.clientActivity;
if (activity?.deviceType === "mobile") {
if (!activity.appVisible) {
return;
}
if (!activity.focusedAgentId) {
return;
}
if (activity.focusedAgentId !== event.agentId) {
return;
}
}
const payload = {
agentId: event.agentId,
event: serializeAgentStreamEvent(event.event),
@@ -90,6 +90,25 @@ function buildPersistence(
function buildToolCallForPrompt(provider: string, prompt: string) {
const text = prompt.toLowerCase();
const createFileMatch =
/create a file named\s+"([^"]+)"\s+with the content\s+"([^"]*)"/i.exec(prompt) ??
/create a file named\s+"([^"]+)"\s+with the content\s+'([^']*)'/i.exec(prompt);
if (createFileMatch) {
const fileName = createFileMatch[1] ?? "test.txt";
const content = createFileMatch[2] ?? "";
if (provider === "codex") {
return {
name: "shell",
input: { command: `printf "%s" "${content}" > ${fileName}` },
output: { ok: true },
};
}
return {
name: "Bash",
input: { command: `printf "%s" "${content}" > ${fileName}` },
output: { ok: true },
};
}
if (provider === "claude") {
if (text.includes("read") && text.includes("/etc/hosts")) {
return { name: "Read", input: { path: "/etc/hosts" }, output: undefined };
@@ -374,6 +393,7 @@ class FakeAgentSession implements AgentSession {
{ id: "default", label: "Default", description: "Ask for permissions" },
{ id: "full-access", label: "Full access", description: "No prompts" },
{ id: "auto", label: "Auto", description: "Ask/allow based on policy" },
{ id: "always-ask", label: "Always Ask", description: "Always prompt" },
];
}
@@ -475,6 +495,20 @@ class FakeAgentSession implements AgentSession {
private buildAssistantText(prompt: string): string {
const lower = prompt.toLowerCase();
// Special-case for tests that ask the agent to run pwd but use a placeholder in the
// "respond with exactly" instruction.
if (lower.includes("run `pwd`") && lower.includes("respond with exactly: cwd:")) {
const cwd = this.config.cwd ?? process.cwd();
return `CWD: ${cwd}`;
}
const respondExactlyMatch =
/respond with exactly:\s*([^\n\r]+)\s*$/i.exec(prompt) ??
/respond with exactly:\s*([^\n\r]+)/i.exec(prompt);
if (respondExactlyMatch) {
return (respondExactlyMatch[1] ?? "").trim();
}
if (lower.includes("state saved")) return "state saved";
if (lower.includes("timeline test")) return "timeline test";
if (lower.includes("quick brown fox") && lower.includes("lazy dog")) {
@@ -499,6 +533,9 @@ class FakeAgentSession implements AgentSession {
prompt: string
): Promise<void> {
const lower = prompt.toLowerCase();
const createFileMatch =
/create a file named\s+"([^"]+)"\s+with the content\s+"([^"]*)"/i.exec(prompt) ??
/create a file named\s+"([^"]+)"\s+with the content\s+'([^']*)'/i.exec(prompt);
if (toolName === "Read" || toolName === "read_file") {
const p = typeof toolInput.path === "string" ? toolInput.path : "/etc/hosts";
@@ -512,6 +549,17 @@ class FakeAgentSession implements AgentSession {
if (toolName === "Bash" || toolName === "shell") {
const command = typeof toolInput.command === "string" ? toolInput.command : "";
// Deterministic file-create behavior for permission prompt tests:
// Prompt: Create a file named "X" with the content "Y"
if (createFileMatch) {
const fileName = createFileMatch[1] ?? "test.txt";
const content = createFileMatch[2] ?? "";
const dest = path.join(this.config.cwd ?? process.cwd(), fileName);
writeFileSync(dest, content);
return;
}
if (lower.includes("rm -f permission.txt") || command.includes("rm -f permission.txt")) {
const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt");
try {
@@ -660,9 +708,18 @@ class FakeAgentClient implements AgentClient {
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [
{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true },
];
if (this.provider === "claude") {
return [
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
{ provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false },
];
}
if (this.provider === "codex") {
return [
{ provider: this.provider, id: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini", isDefault: true },
];
}
return [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }];
}
async isAvailable(): Promise<boolean> {
+3 -4
View File
@@ -98,11 +98,11 @@ function Hero() {
return (
<div className="space-y-6">
<h1 className="text-3xl md:text-5xl font-medium tracking-tight">
Manage coding agents from your phone and desktop
Run Codex and Claude Code locally from your phone and desktop
</h1>
<p className="text-white/70 text-lg leading-relaxed">
Agents run on your machine with your full dev environment. Connect from
phone, desktop, or web.
Use your full dev environment on your own machine. Use voice to check
in, reply, and keep work moving from your phone.
</p>
</div>
)
@@ -337,4 +337,3 @@ function FAQItem({
</details>
)
}