Merge origin/main into local-streaming-speech-tts
This commit is contained in:
@@ -9,7 +9,9 @@ out/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
.env.test.local
|
||||
.env*.local
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ async function main() {
|
||||
if (process.argv.includes("--no-mcp")) {
|
||||
config.mcpEnabled = false;
|
||||
}
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
|
||||
try {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user