Merge origin/main into local-streaming-speech-tts
This commit is contained in:
@@ -9,7 +9,9 @@ out/
|
|||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
.env.test
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.test.local
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "
|
|||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
const repo = await createTempGitRepo();
|
const repo = await createTempGitRepo();
|
||||||
const prompt = "Respond with exactly: Hello";
|
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 ensureHostSelected(page);
|
||||||
await createAgent(page, prompt);
|
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-overflow-menu").click();
|
||||||
await page.getByTestId("agent-menu-details").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")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("agent-details-persistence-session-id-value")
|
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 page.getByTestId("agent-details-agent-id").click();
|
||||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
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();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { test, expect, type Page } from './fixtures';
|
import { test, expect, type Page } from './fixtures';
|
||||||
import {
|
import {
|
||||||
allowPermission,
|
|
||||||
ensureHostSelected,
|
ensureHostSelected,
|
||||||
gotoHome,
|
gotoHome,
|
||||||
setWorkingDirectory,
|
setWorkingDirectory,
|
||||||
waitForPermissionPrompt,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
|
||||||
@@ -21,22 +20,30 @@ function getChangesHeader(page: Page) {
|
|||||||
return getChangesScope(page).getByTestId('changes-header');
|
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') {
|
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||||
const scope = getChangesScope(page);
|
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||||
await scope.getByTestId('changes-view-selector').click();
|
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||||
if (view === 'working') {
|
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||||
await page.getByTestId('changes-mode-uncommitted').click();
|
await primaryBackdrop.click({ force: true });
|
||||||
} else {
|
await expect(primaryBackdrop).toHaveCount(0);
|
||||||
await page.getByTestId('changes-mode-base').click();
|
|
||||||
}
|
}
|
||||||
|
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) {
|
async function openChangesOverflowMenu(page: Page) {
|
||||||
@@ -45,15 +52,13 @@ async function openChangesOverflowMenu(page: Page) {
|
|||||||
await menuButton.click();
|
await menuButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChangesShipMenu(page: Page) {
|
async function openChangesPrimaryMenu(page: Page) {
|
||||||
const backdrop = page.getByRole('button', { name: 'Ship menu backdrop' }).first();
|
|
||||||
if (await backdrop.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scope = getChangesScope(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 expect(caret).toBeVisible();
|
||||||
await caret.click();
|
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 }) {
|
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||||
@@ -61,15 +66,23 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
|||||||
if (!(await changesHeader.isVisible())) {
|
if (!(await changesHeader.isVisible())) {
|
||||||
const explorerHeader = page.getByTestId('explorer-header');
|
const explorerHeader = page.getByTestId('explorer-header');
|
||||||
if (await explorerHeader.isVisible()) {
|
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 {
|
} else {
|
||||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||||
await overflowMenu.click();
|
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) {
|
if (options?.expectGit === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,10 +155,24 @@ async function selectAttachWorktree(page: Page, branchName: string) {
|
|||||||
}, { timeout: 10000 }).toBeTruthy();
|
}, { timeout: 10000 }).toBeTruthy();
|
||||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||||
const scope = sheetVisible ? sheet : page;
|
const scope = sheetVisible ? sheet : page;
|
||||||
const option = scope.getByText(branchName, { exact: true }).first();
|
const preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||||
await expect(option).toBeVisible();
|
if (await preferredOption.isVisible().catch(() => false)) {
|
||||||
await option.click();
|
await preferredOption.click();
|
||||||
await expect(picker).toContainText(branchName);
|
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) {
|
async function enableCreateWorktree(page: Page) {
|
||||||
@@ -194,10 +221,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await waitForAssistantText(page, 'READY');
|
await waitForAssistantText(page, 'READY');
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim();
|
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||||
expect(branchName.length).toBeGreaterThan(0);
|
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 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([
|
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||||
realpath(firstCwd).catch(() => firstCwd),
|
realpath(firstCwd).catch(() => firstCwd),
|
||||||
realpath(repo.path).catch(() => repo.path),
|
realpath(repo.path).catch(() => repo.path),
|
||||||
@@ -206,16 +242,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
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 page.getByTestId('sidebar-new-agent').click();
|
||||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await selectAttachWorktree(page, branchName);
|
await selectAttachWorktree(page, worktreeBranch);
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||||
await waitForAssistantText(page, '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 getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||||
await expect(page.getByText('First change')).toBeVisible();
|
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 selectChangesView(page, 'working');
|
||||||
await refreshChangesTab(page);
|
|
||||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||||
timeout: 30000,
|
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 selectChangesView(page, 'base');
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||||
timeout: 30000,
|
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');
|
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||||
await writeFile(notesPath, 'Second change\n');
|
await writeFile(notesPath, 'Second change\n');
|
||||||
|
|
||||||
@@ -258,17 +320,31 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
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 selectChangesView(page, 'base');
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||||
timeout: 30000,
|
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 selectChangesView(page, 'working');
|
||||||
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
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 selectChangesView(page, 'base');
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
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,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await openChangesShipMenu(page);
|
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||||
await page.getByTestId('changes-ship-create-pr').click();
|
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||||
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
|
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||||
timeout: 60000,
|
await pushPrimary.click();
|
||||||
});
|
// Regression check: the primary CTA stays in place while pushing.
|
||||||
await openChangesShipMenu(page);
|
await expect(pushPrimary).toBeVisible();
|
||||||
await expect(page.getByTestId('changes-ship-open-pr')).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 selectChangesView(page, 'base');
|
||||||
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
|
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
await refreshChangesTab(page);
|
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 openChangesOverflowMenu(page);
|
||||||
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
|
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||||
await page.getByTestId('changes-menu-archive').click();
|
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||||
// Archiving a worktree deletes agents and redirects to home
|
// Archiving a worktree deletes agents and redirects to home
|
||||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
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 page.getByTestId('worktree-attach-toggle').click();
|
||||||
|
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
|
||||||
await page.getByTestId('worktree-attach-picker').click();
|
await page.getByTestId('worktree-attach-picker').click();
|
||||||
await expect(page.getByText(branchName, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
|
||||||
const attachSheetBackdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' });
|
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||||
if (await attachSheetBackdrop.isVisible()) {
|
if (await attachSheet.isVisible().catch(() => false)) {
|
||||||
await attachSheetBackdrop.click({ force: true });
|
await page.getByTestId('dropdown-sheet-close').click({ force: true });
|
||||||
|
await expect(attachSheet).toBeHidden({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
await page.getByTestId('worktree-attach-toggle').click();
|
await page.getByTestId('worktree-attach-toggle').click();
|
||||||
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||||
|
|
||||||
await setWorkingDirectory(page, nonGitDir);
|
await setWorkingDirectory(page, nonGitDir);
|
||||||
const attachPicker = page.getByTestId('worktree-attach-picker');
|
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
|
||||||
if (await attachPicker.isVisible()) {
|
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
|
||||||
await page.getByTestId('worktree-attach-toggle').click();
|
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
||||||
await expect(attachPicker).toBeHidden({ timeout: 30000 });
|
|
||||||
}
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||||
await waitForAssistantText(page, 'NON-GIT');
|
await waitForAssistantText(page, 'NON-GIT');
|
||||||
await openChangesPanel(page, { expectGit: false });
|
await openChangesPanel(page, { expectGit: false });
|
||||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
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 {
|
} finally {
|
||||||
await rm(nonGitDir, { recursive: true, force: true });
|
await rm(nonGitDir, { recursive: true, force: true });
|
||||||
await repo.cleanup();
|
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).
|
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||||
await page.getByTestId('agent-overflow-menu').click();
|
await page.getByTestId('agent-overflow-menu').click();
|
||||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
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
|
// Wait for agent response containing "Hello" within an assistant message
|
||||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
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 }) => {
|
test('daemon is connected in settings', async ({ page }) => {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!daemonPort) {
|
if (!daemonPort) {
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
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 gotoHome(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
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 { test, expect } from './fixtures';
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
|
||||||
async function longPress(page: Page, locator: Locator, durationMs = 1100) {
|
test('deleting an agent persists after reload', async ({ page }) => {
|
||||||
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 }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
const repo = await createTempGitRepo();
|
||||||
const nonce = Math.random().toString(36).slice(2, 10);
|
const nonce = Math.random().toString(36).slice(2, 10);
|
||||||
const prompt = `delete-agent-persists-${nonce}`;
|
const prompt = `respond-ready-${nonce}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoHome(page);
|
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.fill(prompt);
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
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\/([^/]+)\/([^/?#]+)/);
|
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -47,12 +36,11 @@ test('deleting an agent via long-press persists after reload', async ({ page })
|
|||||||
const agentRow = page.getByTestId(rowTestId).first();
|
const agentRow = page.getByTestId(rowTestId).first();
|
||||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
await longPress(page, agentRow, 1200);
|
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||||
|
await agentRow.hover();
|
||||||
const archiveButton = page.getByTestId('agent-action-archive').first();
|
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||||
await expect(archiveButton).toBeVisible({ timeout: 10000 });
|
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||||
await archiveButton.click({ force: true });
|
await quickArchive.click({ force: true });
|
||||||
await expect(page.getByTestId('agent-action-cancel')).toHaveCount(0, { timeout: 10000 });
|
|
||||||
|
|
||||||
// Ensure deletion finished before reload (avoids races).
|
// Ensure deletion finished before reload (avoids races).
|
||||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
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))
|
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||||
.toBe(1);
|
.toBe(1);
|
||||||
|
|
||||||
|
const initialCopyMessageCount = await page
|
||||||
|
.getByRole('button', { name: 'Copy message' })
|
||||||
|
.count();
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
|
|
||||||
const transcriptLocator = page.getByText(/voice note/i);
|
await expect
|
||||||
await expect(transcriptLocator).toBeVisible({ timeout: 60_000 });
|
.poll(
|
||||||
|
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||||
|
{ timeout: 60_000 }
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(initialCopyMessageCount);
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
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 setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||||
await expect(page.getByText('READY', { exact: true })).toBeVisible({ timeout: 60000 });
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -72,6 +74,12 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
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 port = await getAvailablePort();
|
||||||
const relayPort = await getAvailablePort();
|
const relayPort = await getAvailablePort();
|
||||||
const metroPort = 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) => {
|
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 });
|
await expect(promptText).toBeVisible({ timeout });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
export const allowPermission = async (page: Page) => {
|
||||||
const allowButton = page.getByText('Allow', { exact: true }).first();
|
const acceptButton = page.getByTestId('permission-request-accept').first();
|
||||||
await expect(allowButton).toBeVisible({ timeout: 5000 });
|
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||||
await allowButton.click();
|
await acceptButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
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 expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||||
await denyButton.click();
|
await denyButton.click();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { execSync } from 'node:child_process';
|
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 { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
owner?: string;
|
|
||||||
name?: string;
|
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,10 +13,6 @@ export const createTempGitRepo = async (
|
|||||||
options?: { withRemote?: boolean }
|
options?: { withRemote?: boolean }
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
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;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
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' });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
try {
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
owner = execSync('gh api user -q .login', { encoding: 'utf8' }).trim();
|
const remoteDir = path.join(repoPath, 'remote.git');
|
||||||
execSync(
|
await mkdir(remoteDir, { recursive: true });
|
||||||
`gh repo create ${repoName} --private --confirm --source=. --remote=origin --push`,
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||||
{
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||||
cwd: repoPath,
|
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
||||||
stdio: 'ignore',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
await rm(repoPath, { recursive: true, force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
owner,
|
|
||||||
name: repoName,
|
|
||||||
cleanup: async () => {
|
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 });
|
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 }) => {
|
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!daemonPort) {
|
if (!daemonPort) {
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
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(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
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.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.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.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 });
|
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('extra', { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
const removeButtons = page.getByText('Remove', { exact: true });
|
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||||
await expect(removeButtons).toHaveCount(2);
|
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||||
|
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
await page.getByTestId('remove-host-confirm').click();
|
||||||
await removeButtons.nth(1).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||||
await page.waitForFunction(
|
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.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();
|
await page.getByText('Direct connection', { exact: true }).click();
|
||||||
|
|
||||||
const input = page.getByPlaceholder('host:6767');
|
const input = page.getByPlaceholder('host:6767');
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
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.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -7,19 +7,26 @@ import {
|
|||||||
waitForPermissionPrompt,
|
waitForPermissionPrompt,
|
||||||
allowPermission,
|
allowPermission,
|
||||||
denyPermission,
|
denyPermission,
|
||||||
waitForAgentFinishUI,
|
|
||||||
getToolCallCount,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
|
||||||
const FILE_CONTENT = 'Hello from permission test';
|
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.describe('permission prompts', () => {
|
||||||
test('allow permission creates the file', async ({ page }) => {
|
test('allow permission creates the file', async ({ page }) => {
|
||||||
const repo = await createTempGitRepo();
|
const repo = await createTempGitRepo();
|
||||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
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 {
|
try {
|
||||||
await createAgentWithConfig(page, {
|
await createAgentWithConfig(page, {
|
||||||
@@ -31,11 +38,6 @@ test.describe('permission prompts', () => {
|
|||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
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);
|
await allowPermission(page);
|
||||||
|
|
||||||
// Wait for file to be created
|
// Wait for file to be created
|
||||||
@@ -59,7 +61,12 @@ test.describe('permission prompts', () => {
|
|||||||
const repo = await createTempGitRepo();
|
const repo = await createTempGitRepo();
|
||||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
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 {
|
try {
|
||||||
await createAgentWithConfig(page, {
|
await createAgentWithConfig(page, {
|
||||||
@@ -71,23 +78,14 @@ test.describe('permission prompts', () => {
|
|||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
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);
|
await denyPermission(page);
|
||||||
|
|
||||||
// After denying permission, wait for the agent to show the permission denied result
|
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
|
||||||
// The agent might stay in running state but should show a tool call result
|
timeout: 30_000,
|
||||||
await page.waitForTimeout(3000); // Give time for the denial to be processed
|
});
|
||||||
|
|
||||||
expect(existsSync(filePath)).toBe(false);
|
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 {
|
} finally {
|
||||||
await repo.cleanup();
|
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 { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
||||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
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 { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
@@ -37,6 +37,7 @@ import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
|||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||||
|
import { queryClient } from "@/query/query-client";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -98,21 +99,6 @@ function PushNotificationRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function QueryProvider({ children }: { children: ReactNode }) {
|
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>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ export function DropdownSheet({
|
|||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
bottomSheetRef.current?.dismiss();
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetRef.current?.present();
|
bottomSheetRef.current?.present();
|
||||||
@@ -267,6 +272,15 @@ export function DropdownSheet({
|
|||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
<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>
|
</View>
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||||
@@ -990,11 +1004,12 @@ export function GitOptionsSection({
|
|||||||
visible={isWorktreeSheetOpen}
|
visible={isWorktreeSheetOpen}
|
||||||
onClose={() => setIsWorktreeSheetOpen(false)}
|
onClose={() => setIsWorktreeSheetOpen(false)}
|
||||||
>
|
>
|
||||||
{worktreeOptions.map((option) => (
|
{worktreeOptions.map((option, index) => (
|
||||||
<SelectOption
|
<SelectOption
|
||||||
key={option.path}
|
key={option.path}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
selected={option.path === selectedWorktreePath}
|
selected={option.path === selectedWorktreePath}
|
||||||
|
testID={`worktree-attach-option-${index}`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelectWorktreePath(option.path);
|
onSelectWorktreePath(option.path);
|
||||||
setIsWorktreeSheetOpen(false);
|
setIsWorktreeSheetOpen(false);
|
||||||
@@ -1094,6 +1109,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.palette.zinc[600],
|
backgroundColor: theme.colors.palette.zinc[600],
|
||||||
},
|
},
|
||||||
bottomSheetHeader: {
|
bottomSheetHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingBottom: theme.spacing[2],
|
paddingBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export interface AgentStreamViewProps {
|
|||||||
agent: Agent;
|
agent: Agent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
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({
|
export function AgentStreamView({
|
||||||
@@ -92,6 +97,7 @@ export function AgentStreamView({
|
|||||||
agent,
|
agent,
|
||||||
streamItems,
|
streamItems,
|
||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
|
isSyncingHistory = false,
|
||||||
}: AgentStreamViewProps) {
|
}: AgentStreamViewProps) {
|
||||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -590,6 +596,43 @@ export function AgentStreamView({
|
|||||||
return { marginBottom: tightGap };
|
return { marginBottom: tightGap };
|
||||||
}, [listHeaderComponent, 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 (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
@@ -607,13 +650,7 @@ export function AgentStreamView({
|
|||||||
style={stylesheet.list}
|
style={stylesheet.list}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={listEmptyComponent}
|
||||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
|
||||||
<Text style={stylesheet.emptyStateText}>
|
|
||||||
Start chatting with this agent...
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
ListHeaderComponent={listHeaderComponent}
|
ListHeaderComponent={listHeaderComponent}
|
||||||
extraData={flatListExtraData}
|
extraData={flatListExtraData}
|
||||||
maintainVisibleContentPosition={
|
maintainVisibleContentPosition={
|
||||||
@@ -1071,6 +1108,7 @@ function PermissionRequestCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
testID="permission-request-question"
|
||||||
style={[
|
style={[
|
||||||
permissionStyles.question,
|
permissionStyles.question,
|
||||||
{ color: theme.colors.mutedForeground },
|
{ color: theme.colors.mutedForeground },
|
||||||
@@ -1086,6 +1124,7 @@ function PermissionRequestCard({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID="permission-request-deny"
|
||||||
style={(state) => {
|
style={(state) => {
|
||||||
const hovered = Boolean((state as any).hovered);
|
const hovered = Boolean((state as any).hovered);
|
||||||
const pressed = Boolean(state.pressed);
|
const pressed = Boolean(state.pressed);
|
||||||
@@ -1126,6 +1165,7 @@ function PermissionRequestCard({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID="permission-request-accept"
|
||||||
style={(state) => {
|
style={(state) => {
|
||||||
const hovered = Boolean((state as any).hovered);
|
const hovered = Boolean((state as any).hovered);
|
||||||
const pressed = Boolean(state.pressed);
|
const pressed = Boolean(state.pressed);
|
||||||
|
|||||||
@@ -263,37 +263,33 @@ function SidebarContent({
|
|||||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||||
const isGit = status?.isGit ?? false;
|
const isGit = status?.isGit ?? false;
|
||||||
|
|
||||||
// If not a git repo, only show files tab
|
|
||||||
const effectiveTab = isGit ? activeTab : "files";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
{/* Header with tabs and close button */}
|
{/* Header with tabs and close button */}
|
||||||
<View style={styles.header} testID="explorer-header">
|
<View style={styles.header} testID="explorer-header">
|
||||||
<View style={styles.tabsContainer}>
|
<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
|
<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")}
|
onPress={() => onTabPress("files")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
effectiveTab === "files" && styles.tabTextActive,
|
activeTab === "files" && styles.tabTextActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
@@ -311,10 +307,10 @@ function SidebarContent({
|
|||||||
|
|
||||||
{/* Content based on active tab */}
|
{/* Content based on active tab */}
|
||||||
<View style={styles.contentArea} testID="explorer-content-area">
|
<View style={styles.contentArea} testID="explorer-content-area">
|
||||||
{effectiveTab === "changes" && (
|
{activeTab === "changes" && (
|
||||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||||
)}
|
)}
|
||||||
{effectiveTab === "files" && (
|
{activeTab === "files" && (
|
||||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -389,6 +385,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabTextActive: {
|
tabTextActive: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
|
tabTextMuted: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
headerRightSection: {
|
headerRightSection: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} 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 { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDownloadStore } from "@/stores/download-store";
|
import { useDownloadStore } from "@/stores/download-store";
|
||||||
@@ -312,6 +316,11 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
|||||||
const showInitialLoading =
|
const showInitialLoading =
|
||||||
!directories.has(".") &&
|
!directories.has(".") &&
|
||||||
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
||||||
|
const showBackFromError = Boolean(error && selectedEntryPath);
|
||||||
|
const errorRecoveryPath = useMemo(
|
||||||
|
() => getErrorRecoveryPath(explorerState),
|
||||||
|
[explorerState]
|
||||||
|
);
|
||||||
|
|
||||||
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
||||||
const minTreeWidth = 220;
|
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) {
|
if (!agentExists) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -491,19 +511,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
<Pressable
|
<View style={styles.errorActions}>
|
||||||
style={styles.retryButton}
|
{showBackFromError ? (
|
||||||
onPress={() => {
|
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||||
if (agentId) {
|
<Text style={styles.retryButtonText}>Back</Text>
|
||||||
requestDirectoryListing(agentId, ".", {
|
</Pressable>
|
||||||
recordHistory: false,
|
) : null}
|
||||||
setCurrentPath: false,
|
<Pressable
|
||||||
});
|
style={styles.retryButton}
|
||||||
}
|
onPress={() => {
|
||||||
}}
|
if (agentId) {
|
||||||
>
|
requestDirectoryListing(agentId, ".", {
|
||||||
<Text style={styles.retryButtonText}>Retry</Text>
|
recordHistory: false,
|
||||||
</Pressable>
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryButtonText}>Retry</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : showInitialLoading ? (
|
) : showInitialLoading ? (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -960,6 +987,21 @@ function getAncestorDirectories(directory: string): string[] {
|
|||||||
return ancestors;
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1065,6 +1107,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
|
errorActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
|
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 { useRouter } from "expo-router";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -17,8 +16,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
|
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import {
|
import {
|
||||||
useCheckoutDiffQuery,
|
useCheckoutDiffQuery,
|
||||||
type ParsedDiffFile,
|
type ParsedDiffFile,
|
||||||
@@ -40,20 +38,6 @@ import {
|
|||||||
type ActionStatus,
|
type ActionStatus,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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
|
// Git Actions Data Structure
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -85,47 +69,6 @@ interface GitActions {
|
|||||||
menu: GitAction[];
|
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 {
|
function openURLInNewTab(url: string): void {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
window.open(url, "_blank", "noopener");
|
window.open(url, "_blank", "noopener");
|
||||||
@@ -480,10 +423,6 @@ type GitDiffSection = {
|
|||||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const client = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
|
||||||
);
|
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
@@ -669,157 +608,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
});
|
});
|
||||||
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
||||||
|
|
||||||
const commitMutation = useMutation({
|
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||||
mutationFn: async () => {
|
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||||
if (!client) {
|
);
|
||||||
throw new Error("Daemon client unavailable");
|
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||||
}
|
state.getStatus({ serverId, cwd, actionId: "push" })
|
||||||
const payload = await client.checkoutCommit(cwd, { addAll: true });
|
);
|
||||||
if (payload.error) {
|
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||||
throw new Error(payload.error.message);
|
state.getStatus({ serverId, cwd, actionId: "create-pr" })
|
||||||
}
|
);
|
||||||
return payload;
|
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||||
},
|
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
|
||||||
onSuccess: () => {
|
);
|
||||||
setActionError(null);
|
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||||
void refreshDiff();
|
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
|
||||||
void refreshStatus();
|
);
|
||||||
},
|
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||||
onError: (err) => {
|
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";
|
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
},
|
});
|
||||||
});
|
}, [runCommit, serverId, cwd]);
|
||||||
|
|
||||||
const prMutation = useMutation({
|
const handlePush = useCallback(() => {
|
||||||
mutationFn: async () => {
|
setActionError(null);
|
||||||
if (!client) {
|
void runPush({ serverId, cwd }).catch((err) => {
|
||||||
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 message = err instanceof Error ? err.message : "Failed to push";
|
const message = err instanceof Error ? err.message : "Failed to push";
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
},
|
});
|
||||||
});
|
}, [runPush, serverId, cwd]);
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const handleCreatePr = useCallback(() => {
|
||||||
mutationFn: async () => {
|
void persistShipDefault("pr");
|
||||||
if (!client) {
|
setActionError(null);
|
||||||
throw new Error("Daemon client unavailable");
|
void runCreatePr({ serverId, cwd }).catch((err) => {
|
||||||
}
|
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||||
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";
|
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
},
|
});
|
||||||
});
|
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
||||||
|
|
||||||
// Wrap mutations with action status for UI feedback
|
const handleMergeBranch = useCallback(() => {
|
||||||
const commitAction = useActionStatus(commitMutation);
|
if (!baseRef) {
|
||||||
const prCreateAction = useActionStatus(prMutation, () => void persistShipDefault("pr"));
|
setActionError("Base ref unavailable");
|
||||||
const mergeAction = useActionStatus(mergeMutation, () => void persistShipDefault("merge"));
|
return;
|
||||||
const mergeFromBaseAction = useActionStatus(mergeFromBaseMutation);
|
}
|
||||||
const pushAction = useActionStatus(pushMutation);
|
void persistShipDefault("merge");
|
||||||
const archiveAction = useActionStatus(archiveMutation);
|
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(
|
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
|
||||||
({ item, section }) => (
|
({ item, section }) => (
|
||||||
@@ -861,16 +741,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||||
}, [baseRef]);
|
}, [baseRef]);
|
||||||
const commitDisabled = actionsDisabled || commitMutation.isPending;
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
const prDisabled = actionsDisabled || prMutation.isPending;
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
|
const mergeDisabled =
|
||||||
|
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||||
const mergeFromBaseDisabled =
|
const mergeFromBaseDisabled =
|
||||||
actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges;
|
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||||
const pushDisabled =
|
const pushDisabled =
|
||||||
actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false);
|
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||||
const archiveDisabled =
|
const archiveDisabled =
|
||||||
actionsDisabled ||
|
actionsDisabled ||
|
||||||
archiveMutation.isPending ||
|
archiveStatus === "pending" ||
|
||||||
!gitStatus?.isPaseoOwnedWorktree;
|
!gitStatus?.isPaseoOwnedWorktree;
|
||||||
|
|
||||||
let bodyContent: ReactElement;
|
let bodyContent: ReactElement;
|
||||||
@@ -966,8 +847,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
pendingLabel: "Committing...",
|
pendingLabel: "Committing...",
|
||||||
successLabel: "Committed",
|
successLabel: "Committed",
|
||||||
disabled: commitDisabled,
|
disabled: commitDisabled,
|
||||||
status: commitAction.status,
|
status: commitStatus,
|
||||||
handler: commitAction.trigger,
|
handler: handleCommit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push - when has remote
|
// Push - when has remote
|
||||||
@@ -978,9 +859,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
pendingLabel: "Pushing...",
|
pendingLabel: "Pushing...",
|
||||||
successLabel: "Pushed",
|
successLabel: "Pushed",
|
||||||
disabled: pushDisabled,
|
disabled: pushDisabled,
|
||||||
status: pushAction.status,
|
status: pushStatus,
|
||||||
description: !hasRemote ? "No remote configured" : undefined,
|
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...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled: prDisabled,
|
disabled: prDisabled,
|
||||||
status: prCreateAction.status,
|
status: prCreateStatus,
|
||||||
handler: prCreateAction.trigger,
|
handler: handleCreatePr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,9 +900,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
pendingLabel: "Merging...",
|
pendingLabel: "Merging...",
|
||||||
successLabel: "Merged",
|
successLabel: "Merged",
|
||||||
disabled: mergeDisabled,
|
disabled: mergeDisabled,
|
||||||
status: mergeAction.status,
|
status: mergeStatus,
|
||||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
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...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
successLabel: "Updated",
|
||||||
disabled: mergeFromBaseDisabled,
|
disabled: mergeFromBaseDisabled,
|
||||||
status: mergeFromBaseAction.status,
|
status: mergeFromBaseStatus,
|
||||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
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...",
|
pendingLabel: "Archiving...",
|
||||||
successLabel: "Archived",
|
successLabel: "Archived",
|
||||||
disabled: archiveDisabled,
|
disabled: archiveDisabled,
|
||||||
status: archiveAction.status,
|
status: archiveStatus,
|
||||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
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";
|
primaryActionId = "commit";
|
||||||
}
|
}
|
||||||
// Rule 2: Ahead of origin → Push
|
// Rule 2: Ahead of origin → Push
|
||||||
else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
|
else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||||
primaryActionId = "push";
|
primaryActionId = "push";
|
||||||
}
|
}
|
||||||
// Rule 3: Has PR → View PR
|
// 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,
|
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
|
||||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
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
|
// Helper to get display label based on status
|
||||||
@@ -1216,7 +1098,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
|
{isGit ? (
|
||||||
<View style={styles.diffStatusContainer}>
|
<View style={styles.diffStatusContainer}>
|
||||||
<View style={styles.diffStatusInner}>
|
<View style={styles.diffStatusInner}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
DraggableList,
|
DraggableList,
|
||||||
type DraggableRenderItemInfo,
|
type DraggableRenderItemInfo,
|
||||||
} from "./draggable-list";
|
} from "./draggable-list";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
|
||||||
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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({
|
export function GroupedAgentList({
|
||||||
agents,
|
agents,
|
||||||
isRefreshing = false,
|
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(
|
const renderSection = useCallback(
|
||||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||||
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
||||||
@@ -454,12 +511,34 @@ export function GroupedAgentList({
|
|||||||
/>
|
/>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
section.agents.map((agent) => (
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
|
[
|
||||||
|
collapsedProjectKeys,
|
||||||
|
handleAgentLongPress,
|
||||||
|
handleAgentPress,
|
||||||
|
handleArchiveAgent,
|
||||||
|
handleCreateAgentInProject,
|
||||||
|
selectedAgentId,
|
||||||
|
showShortcutBadges,
|
||||||
|
shortcutIndexByAgentKey,
|
||||||
|
toggleProjectCollapsed,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback(
|
||||||
@@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
lineHeight: 12,
|
lineHeight: 12,
|
||||||
},
|
},
|
||||||
|
archiveConfirmBadge: {
|
||||||
|
minWidth: 76,
|
||||||
|
maxWidth: 9999,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
archiveConfirmText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
archiveConfirmTextHovered: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
agentTitleHighlighted: {
|
agentTitleHighlighted: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 1,
|
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 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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Menu, PanelLeft } from "lucide-react-native";
|
import { Menu, PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={toggleAgentList}
|
||||||
style={styles.menuButton}
|
tooltipLabel="Toggle sidebar"
|
||||||
|
tooltipKeys={["mod", "B"]}
|
||||||
|
tooltipSide="right"
|
||||||
testID="menu-button"
|
testID="menu-button"
|
||||||
nativeID="menu-button"
|
nativeID="menu-button"
|
||||||
collapsable={false}
|
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||||
accessibilityState={{ expanded: isOpen }}
|
accessibilityState={{ expanded: isOpen }}
|
||||||
>
|
>
|
||||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||||
</Pressable>
|
</HeaderToggleButton>
|
||||||
{title && (
|
{title && (
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: {
|
left: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
menuButton: {
|
|
||||||
padding: {
|
|
||||||
xs: theme.spacing[3],
|
|
||||||
md: theme.spacing[2],
|
|
||||||
},
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
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
|
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||||
// on status changes, which is sufficient for sorting and display purposes.
|
// on status changes, which is sufficient for sorting and display purposes.
|
||||||
@@ -956,7 +946,7 @@ export function SessionProvider({
|
|||||||
clearAgentStreamHead(serverId, agentId);
|
clearAgentStreamHead(serverId, agentId);
|
||||||
|
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
if (!prev.has(agentId)) {
|
if (prev.get(agentId) !== true) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -990,16 +980,6 @@ export function SessionProvider({
|
|||||||
requestId: (message.payload as any).requestId,
|
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(
|
const unsubPermissionRequest = client.on(
|
||||||
@@ -1443,12 +1423,6 @@ export function SessionProvider({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, []);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
clearAgentStreamHead(serverId, agentId);
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
@@ -1470,24 +1444,17 @@ export function SessionProvider({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
|
[serverId, client, setInitializingAgents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshAgent = useCallback(
|
const refreshAgent = useCallback(
|
||||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
({ agentId }: { agentId: string }) => {
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, true);
|
next.set(agentId, true);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, []);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
clearAgentStreamHead(serverId, agentId);
|
|
||||||
|
|
||||||
refreshAgentMutation
|
refreshAgentMutation
|
||||||
.mutateAsync({ agentId })
|
.mutateAsync({ agentId })
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -1499,13 +1466,7 @@ export function SessionProvider({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[serverId, refreshAgentMutation, setInitializingAgents]
|
||||||
serverId,
|
|
||||||
refreshAgentMutation,
|
|
||||||
setAgentStreamTail,
|
|
||||||
setInitializingAgents,
|
|
||||||
clearAgentStreamHead,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendAgentMessage = useCallback(
|
const sendAgentMessage = useCallback(
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
|
attachInitTimeout,
|
||||||
createInitDeferred,
|
createInitDeferred,
|
||||||
getInitDeferred,
|
getInitDeferred,
|
||||||
getInitKey,
|
getInitKey,
|
||||||
rejectInitDeferred,
|
rejectInitDeferred,
|
||||||
} from "@/utils/agent-initialization";
|
} from "@/utils/agent-initialization";
|
||||||
|
|
||||||
const INIT_TIMEOUT_MS = 10000;
|
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
export function useAgentInitialization(serverId: string) {
|
export function useAgentInitialization(serverId: string) {
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
|
||||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
|
||||||
|
|
||||||
const ensureAgentIsInitialized = useCallback(
|
const ensureAgentIsInitialized = useCallback(
|
||||||
(agentId: string): Promise<void> => {
|
(agentId: string): Promise<void> => {
|
||||||
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deferred = createInitDeferred(key);
|
const deferred = createInitDeferred(key);
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
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);
|
}, INIT_TIMEOUT_MS);
|
||||||
|
attachInitTimeout(key, timeoutId);
|
||||||
|
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, []);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
clearAgentStreamHead(serverId, agentId);
|
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, false);
|
next.set(agentId, false);
|
||||||
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
client
|
client
|
||||||
.initializeAgent(agentId)
|
.initializeAgent(agentId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
clearTimeout(timeoutId);
|
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
|
||||||
|
// message, handled in SessionContext.
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, false);
|
next.set(agentId, false);
|
||||||
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
},
|
},
|
||||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
[client, serverId, setInitializingAgents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshAgent = useCallback(
|
const refreshAgent = useCallback(
|
||||||
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, []);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
clearAgentStreamHead(serverId, agentId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.refreshAgent(agentId);
|
await client.refreshAgent(agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
[client, serverId, setInitializingAgents]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ensureAgentIsInitialized, refreshAgent };
|
return { ensureAgentIsInitialized, refreshAgent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
|||||||
appVisibleRef.current = nextState === "active";
|
appVisibleRef.current = nextState === "active";
|
||||||
if (nextState === "active") {
|
if (nextState === "active") {
|
||||||
recordUserActivity();
|
recordUserActivity();
|
||||||
maybeSendImmediateHeartbeat();
|
|
||||||
}
|
}
|
||||||
|
// Send immediately on visibility changes so the server can adapt streaming behavior.
|
||||||
|
sendHeartbeat();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
}, [recordUserActivity, sendHeartbeat]);
|
||||||
|
|
||||||
// Track user activity on web for accurate staleness.
|
// Track user activity on web for accurate staleness.
|
||||||
useEffect(() => {
|
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,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
|
AppState,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
||||||
@@ -502,6 +504,46 @@ function AgentScreenContent({
|
|||||||
});
|
});
|
||||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return;
|
return;
|
||||||
@@ -569,7 +611,17 @@ function AgentScreenContent({
|
|||||||
title={effectiveAgent.title || "Agent"}
|
title={effectiveAgent.title || "Agent"}
|
||||||
rightContent={
|
rightContent={
|
||||||
<View style={styles.headerRightContent}>
|
<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 ? (
|
{isMobile ? (
|
||||||
checkout?.isGit ? (
|
checkout?.isGit ? (
|
||||||
<GitBranch
|
<GitBranch
|
||||||
@@ -600,7 +652,7 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</HeaderToggleButton>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open && agent?.cwd) {
|
if (open && agent?.cwd) {
|
||||||
@@ -712,28 +764,21 @@ function AgentScreenContent({
|
|||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[styles.content, animatedKeyboardStyle]}
|
style={[styles.content, animatedKeyboardStyle]}
|
||||||
>
|
>
|
||||||
{isInitializing && !shouldUseOptimisticStream ? (
|
<AgentStreamView
|
||||||
<View style={styles.loadingContainer}>
|
agentId={effectiveAgent.id}
|
||||||
<ActivityIndicator
|
serverId={serverId}
|
||||||
size="large"
|
agent={effectiveAgent}
|
||||||
color={theme.colors.primary}
|
streamItems={
|
||||||
/>
|
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
}
|
||||||
</View>
|
pendingPermissions={pendingPermissions}
|
||||||
) : (
|
isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
|
||||||
<AgentStreamView
|
/>
|
||||||
agentId={effectiveAgent.id}
|
|
||||||
serverId={serverId}
|
|
||||||
agent={effectiveAgent}
|
|
||||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
|
||||||
pendingPermissions={pendingPermissions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Agent Input Area */}
|
{/* Agent Input Area */}
|
||||||
{!isInitializing && agent && resolvedAgentId && (
|
{agent && resolvedAgentId && (
|
||||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
<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
|
timestamp: Date
|
||||||
): string {
|
): string {
|
||||||
const base = createTimelineId(prefix, text, timestamp);
|
const base = createTimelineId(prefix, text, timestamp);
|
||||||
let suffixSeed = state.length;
|
// We only ever append new timeline items, and we incorporate the current
|
||||||
let candidate = `${base}_${suffixSeed.toString(36)}`;
|
// length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
|
||||||
|
// collision scan (important for large hydration snapshots).
|
||||||
// Fast path when the generated id hasn't been used yet
|
const suffixSeed = state.length;
|
||||||
const hasCollision = state.some((entry) => entry.id === candidate);
|
return `${base}_${suffixSeed.toString(36)}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StreamItem =
|
export type StreamItem =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface DeferredInit {
|
|||||||
promise: Promise<void>;
|
promise: Promise<void>;
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initPromises = new Map<string, DeferredInit>();
|
const initPromises = new Map<string, DeferredInit>();
|
||||||
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
|
|||||||
reject = rej;
|
reject = rej;
|
||||||
});
|
});
|
||||||
|
|
||||||
const deferred: DeferredInit = { promise, resolve, reject };
|
const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
|
||||||
initPromises.set(key, deferred);
|
initPromises.set(key, deferred);
|
||||||
return 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 {
|
export function resolveInitDeferred(key: string): void {
|
||||||
const deferred = initPromises.get(key);
|
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 {
|
export function rejectInitDeferred(key: string, error: Error): void {
|
||||||
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
|
|||||||
if (!deferred) {
|
if (!deferred) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deferred.timeoutId) {
|
||||||
|
clearTimeout(deferred.timeoutId);
|
||||||
|
}
|
||||||
initPromises.delete(key);
|
initPromises.delete(key);
|
||||||
deferred.reject(error);
|
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 { 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 */
|
/** Model list item for display */
|
||||||
export interface ModelListItem {
|
export interface ModelListItem {
|
||||||
model: string
|
model: string
|
||||||
id: string
|
id: string
|
||||||
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Static model data by provider */
|
const VALID_PROVIDERS = ['claude', 'codex', 'opencode']
|
||||||
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' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schema for provider models output */
|
/** Schema for provider models output */
|
||||||
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
columns: [
|
columns: [
|
||||||
{ header: 'MODEL', field: 'model', width: 30 },
|
|
||||||
{ header: 'ID', field: 'id', 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(
|
export async function runModelsCommand(
|
||||||
provider: string,
|
provider: string,
|
||||||
_options: ProviderModelsOptions,
|
options: ProviderModelsOptions,
|
||||||
_command: Command
|
_command: Command
|
||||||
): Promise<ProviderModelsResult> {
|
): Promise<ProviderModelsResult> {
|
||||||
const normalizedProvider = provider.toLowerCase()
|
const normalizedProvider = provider.toLowerCase()
|
||||||
const models = MODELS_BY_PROVIDER[normalizedProvider]
|
|
||||||
|
|
||||||
if (!models) {
|
if (!VALID_PROVIDERS.includes(normalizedProvider)) {
|
||||||
const validProviders = Object.keys(MODELS_BY_PROVIDER).join(', ')
|
throw {
|
||||||
const error: CommandError = {
|
|
||||||
code: 'UNKNOWN_PROVIDER',
|
code: 'UNKNOWN_PROVIDER',
|
||||||
message: `Unknown provider: ${provider}`,
|
message: `Unknown provider: ${provider}`,
|
||||||
details: `Valid providers: ${validProviders}`,
|
details: `Valid providers: ${VALID_PROVIDERS.join(', ')}`,
|
||||||
}
|
}
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const client = await connectToDaemon({ host: options.host })
|
||||||
type: 'list',
|
try {
|
||||||
data: models,
|
const result = await client.listProviderModels(normalizedProvider)
|
||||||
schema: providerModelsSchema,
|
|
||||||
|
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
|
conversations
|
||||||
.cache
|
.cache
|
||||||
.debug/
|
.debug/
|
||||||
|
.env.test
|
||||||
|
|||||||
@@ -494,4 +494,125 @@ describe("AgentManager", () => {
|
|||||||
const updatedAgent = manager.getAgent(snapshot.id);
|
const updatedAgent = manager.getAgent(snapshot.id);
|
||||||
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
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.pendingRun = null;
|
||||||
mutableAgent.lifecycle = error ? "error" : "idle";
|
mutableAgent.lifecycle = error ? "error" : "idle";
|
||||||
mutableAgent.lastError = error;
|
mutableAgent.lastError = error;
|
||||||
mutableAgent.persistence = attachPersistenceCwd(
|
const persistenceHandle =
|
||||||
mutableAgent.session.describePersistence() ??
|
mutableAgent.session.describePersistence() ??
|
||||||
(mutableAgent.runtimeInfo?.sessionId
|
(mutableAgent.runtimeInfo?.sessionId
|
||||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||||
: null),
|
: null);
|
||||||
mutableAgent.cwd
|
if (persistenceHandle) {
|
||||||
);
|
mutableAgent.persistence = attachPersistenceCwd(
|
||||||
|
persistenceHandle,
|
||||||
|
mutableAgent.cwd
|
||||||
|
);
|
||||||
|
}
|
||||||
this.emitState(mutableAgent);
|
this.emitState(mutableAgent);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1113,7 +1117,12 @@ export class AgentManager {
|
|||||||
case "thread_started":
|
case "thread_started":
|
||||||
// Update persistence with the new session ID from the provider.
|
// Update persistence with the new session ID from the provider.
|
||||||
// persistence.sessionId is the single source of truth for session identity.
|
// 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;
|
break;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
this.recordTimeline(agent, event.item);
|
this.recordTimeline(agent, event.item);
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ type ClaudeAgentClientOptions = {
|
|||||||
|
|
||||||
type ClaudeAgentSessionOptions = {
|
type ClaudeAgentSessionOptions = {
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
claudePath: string | null;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
};
|
};
|
||||||
@@ -315,16 +316,23 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
private readonly claudePath: string | null;
|
||||||
|
|
||||||
constructor(options: ClaudeAgentClientOptions) {
|
constructor(options: ClaudeAgentClientOptions) {
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
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> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
const claudeConfig = this.assertConfig(config);
|
const claudeConfig = this.assertConfig(config);
|
||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
|
claudePath: this.claudePath,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,6 +350,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
const claudeConfig = this.assertConfig(mergedConfig);
|
const claudeConfig = this.assertConfig(mergedConfig);
|
||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
|
claudePath: this.claudePath,
|
||||||
handle,
|
handle,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
@@ -353,6 +362,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
cwd: options?.cwd ?? process.cwd(),
|
cwd: options?.cwd ?? process.cwd(),
|
||||||
permissionMode: "plan",
|
permissionMode: "plan",
|
||||||
includePartialMessages: false,
|
includePartialMessages: false,
|
||||||
|
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const claudeQuery = query({ prompt, options: claudeOptions });
|
const claudeQuery = query({ prompt, options: claudeOptions });
|
||||||
@@ -407,12 +417,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
return this.claudePath !== null;
|
||||||
const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
|
|
||||||
return Boolean(claudePath);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
|
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
|
||||||
@@ -429,6 +434,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private readonly config: ClaudeAgentConfig;
|
private readonly config: ClaudeAgentConfig;
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
private readonly claudePath: string | null;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private query: Query | null = null;
|
private query: Query | null = null;
|
||||||
private input: Pushable<SDKUserMessage> | null = null;
|
private input: Pushable<SDKUserMessage> | null = null;
|
||||||
@@ -464,6 +470,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
|
this.claudePath = options.claudePath;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
const handle = options.handle;
|
const handle = options.handle;
|
||||||
|
|
||||||
@@ -880,6 +887,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
permissionMode: this.currentMode,
|
permissionMode: this.currentMode,
|
||||||
agents: this.defaults?.agents,
|
agents: this.defaults?.agents,
|
||||||
canUseTool: this.handlePermissionRequest,
|
canUseTool: this.handlePermissionRequest,
|
||||||
|
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
||||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
// Use Claude Code preset system prompt and load CLAUDE.md files
|
||||||
// Append orchestrator mode instructions for agents
|
// Append orchestrator mode instructions for agents
|
||||||
systemPrompt: {
|
systemPrompt: {
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.run("Reply with OK.");
|
|
||||||
const info = await session.getRuntimeInfo();
|
const info = await session.getRuntimeInfo();
|
||||||
await session.close();
|
await session.close();
|
||||||
|
|
||||||
@@ -730,9 +729,20 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
if (captured) {
|
if (captured) {
|
||||||
expect(sawPermissionResolved).toBe(true);
|
expect(sawPermissionResolved).toBe(true);
|
||||||
}
|
}
|
||||||
const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 5000 });
|
const sawPatch = timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"));
|
||||||
expect(timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"))).toBe(true);
|
expect(sawPatch).toBe(true);
|
||||||
expect(text?.trim()).toBe("ok");
|
|
||||||
|
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 {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ import { Dirent } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
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 DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
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(
|
function threadItemToTimeline(
|
||||||
item: any,
|
item: any,
|
||||||
options?: { includeUserMessage?: boolean }
|
options?: { includeUserMessage?: boolean; cwd?: string | null }
|
||||||
): AgentTimelineItem | null {
|
): AgentTimelineItem | null {
|
||||||
if (!item || typeof item !== "object") return null;
|
if (!item || typeof item !== "object") return null;
|
||||||
const includeUserMessage = options?.includeUserMessage ?? true;
|
const includeUserMessage = options?.includeUserMessage ?? true;
|
||||||
|
const cwd = options?.cwd ?? null;
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "userMessage": {
|
case "userMessage": {
|
||||||
if (!includeUserMessage) {
|
if (!includeUserMessage) {
|
||||||
@@ -722,14 +739,17 @@ function threadItemToTimeline(
|
|||||||
case "fileChange": {
|
case "fileChange": {
|
||||||
const files = Array.isArray(item.changes)
|
const files = Array.isArray(item.changes)
|
||||||
? item.changes.map((change: any) => ({
|
? item.changes.map((change: any) => ({
|
||||||
path: change.path,
|
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
|
||||||
kind: change.kind,
|
kind: change.kind,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const outputFiles = Array.isArray(item.changes)
|
const outputFiles = Array.isArray(item.changes)
|
||||||
? item.changes.map((change: any) => ({
|
? item.changes.map((change: any) => ({
|
||||||
path: change.path,
|
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
|
||||||
patch: change.diff,
|
patch:
|
||||||
|
typeof change.diff === "string"
|
||||||
|
? truncateUtf8Bytes(change.diff, MAX_FILE_PATCH_BYTES).text
|
||||||
|
: change.diff,
|
||||||
kind: change.kind,
|
kind: change.kind,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
@@ -823,6 +843,223 @@ function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
|
|||||||
return { mimeType, data };
|
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> {
|
async function writeImageAttachment(mimeType: string, data: string): Promise<string> {
|
||||||
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
|
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
|
||||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||||
@@ -899,6 +1136,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private resolvedPermissionRequests = new Set<string>();
|
private resolvedPermissionRequests = new Set<string>();
|
||||||
private pendingAgentMessages = new Map<string, string>();
|
private pendingAgentMessages = new Map<string, string>();
|
||||||
private pendingReasoning = 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 latestUsage: AgentUsage | undefined;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private collaborationModes: Array<{
|
private collaborationModes: Array<{
|
||||||
@@ -1054,22 +1293,40 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async loadPersistedHistory(): Promise<void> {
|
private async loadPersistedHistory(): Promise<void> {
|
||||||
if (!this.client || !this.currentThreadId) return;
|
if (!this.client || !this.currentThreadId) return;
|
||||||
try {
|
try {
|
||||||
|
let rolloutTimeline: AgentTimelineItem[] = [];
|
||||||
|
try {
|
||||||
|
rolloutTimeline = await loadCodexPersistedTimeline(
|
||||||
|
this.currentThreadId,
|
||||||
|
undefined,
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
rolloutTimeline = [];
|
||||||
|
}
|
||||||
|
|
||||||
const response = (await this.client.request("thread/read", {
|
const response = (await this.client.request("thread/read", {
|
||||||
threadId: this.currentThreadId,
|
threadId: this.currentThreadId,
|
||||||
includeTurns: true,
|
includeTurns: true,
|
||||||
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
||||||
const thread = response?.thread;
|
const thread = response?.thread;
|
||||||
if (!thread || !Array.isArray(thread.turns)) return;
|
const threadTimeline: AgentTimelineItem[] = [];
|
||||||
const timeline: AgentTimelineItem[] = [];
|
if (thread && Array.isArray(thread.turns)) {
|
||||||
for (const turn of thread.turns) {
|
for (const turn of thread.turns) {
|
||||||
const items = Array.isArray(turn.items) ? turn.items : [];
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const timelineItem = threadItemToTimeline(item);
|
const timelineItem = threadItemToTimeline(item, {
|
||||||
if (timelineItem) {
|
cwd: this.config.cwd ?? null,
|
||||||
timeline.push(timelineItem);
|
});
|
||||||
|
if (timelineItem) {
|
||||||
|
threadTimeline.push(timelineItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeline =
|
||||||
|
rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
|
||||||
|
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
this.persistedHistory = timeline;
|
this.persistedHistory = timeline;
|
||||||
this.historyPending = true;
|
this.historyPending = true;
|
||||||
@@ -1176,7 +1433,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
params.outputSchema = options.outputSchema;
|
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) {
|
for await (const event of queue) {
|
||||||
yield event;
|
yield event;
|
||||||
@@ -1209,6 +1466,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||||
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
|
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
|
||||||
|
if (!this.connected) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
if (!this.currentThreadId) {
|
||||||
|
await this.ensureThread();
|
||||||
|
}
|
||||||
const info: AgentRuntimeInfo = {
|
const info: AgentRuntimeInfo = {
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
sessionId: this.currentThreadId,
|
sessionId: this.currentThreadId,
|
||||||
@@ -1479,125 +1742,141 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private handleNotification(method: string, params: unknown): void {
|
private handleNotification(method: string, params: unknown): void {
|
||||||
this.emitEvent({ type: "provider_event", provider: CODEX_PROVIDER, raw: { method, params } });
|
this.emitEvent({ type: "provider_event", provider: CODEX_PROVIDER, raw: { method, params } });
|
||||||
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||||
|
|
||||||
switch (method) {
|
if (parsed.kind === "thread_started") {
|
||||||
case "thread/started": {
|
this.currentThreadId = parsed.threadId;
|
||||||
const threadId = (params as any)?.thread?.id;
|
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: parsed.threadId });
|
||||||
if (threadId) {
|
return;
|
||||||
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 === "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> {
|
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
|
||||||
@@ -1741,19 +2020,27 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
const title = thread.preview ?? null;
|
const title = thread.preview ?? null;
|
||||||
let timeline: AgentTimelineItem[] = [];
|
let timeline: AgentTimelineItem[] = [];
|
||||||
try {
|
try {
|
||||||
|
const rolloutTimeline = await loadCodexPersistedTimeline(
|
||||||
|
threadId,
|
||||||
|
undefined,
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
const read = (await client.request("thread/read", {
|
const read = (await client.request("thread/read", {
|
||||||
threadId,
|
threadId,
|
||||||
includeTurns: true,
|
includeTurns: true,
|
||||||
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
||||||
const turns = read.thread?.turns ?? [];
|
const turns = read.thread?.turns ?? [];
|
||||||
const items: AgentTimelineItem[] = [];
|
const itemsFromThreadRead: AgentTimelineItem[] = [];
|
||||||
for (const turn of turns) {
|
for (const turn of turns) {
|
||||||
for (const item of turn.items ?? []) {
|
for (const item of turn.items ?? []) {
|
||||||
const timelineItem = threadItemToTimeline(item);
|
const timelineItem = threadItemToTimeline(item, { cwd });
|
||||||
if (timelineItem) items.push(timelineItem);
|
if (timelineItem) itemsFromThreadRead.push(timelineItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timeline = items;
|
timeline =
|
||||||
|
rolloutTimeline.length > 0
|
||||||
|
? rolloutTimeline
|
||||||
|
: itemsFromThreadRead;
|
||||||
} catch {
|
} catch {
|
||||||
timeline = [];
|
timeline = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5097,10 +5097,20 @@ async function findRolloutFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RolloutContentItemSchema = z.object({
|
const RolloutContentItemSchema = z
|
||||||
type: z.string(),
|
.union([
|
||||||
text: z.string(),
|
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||||
}).passthrough();
|
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);
|
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||||
|
|
||||||
@@ -5116,77 +5126,131 @@ function extractContentTextByType(content: unknown, itemType: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const RolloutResponsePayloadSchema = z.object({
|
const RolloutResponseMessagePayloadSchema = z.object({
|
||||||
type: z.string().optional(),
|
type: z.literal("message"),
|
||||||
role: z.string().optional(),
|
role: z.enum(["user", "assistant"]).optional(),
|
||||||
|
content: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseReasoningPayloadSchema = z.object({
|
||||||
|
type: z.literal("reasoning"),
|
||||||
content: z.unknown().optional(),
|
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(),
|
summary: z.array(z.object({ text: z.string().optional() })).optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const RolloutEventPayloadSchema = z.object({
|
const RolloutResponseFunctionCallPayloadSchema = z.object({
|
||||||
type: z.string().optional(),
|
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(),
|
text: z.string().optional(),
|
||||||
message: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const RolloutEntrySchema = z.object({
|
const RolloutEventAgentMessagePayloadSchema = z.object({
|
||||||
type: z.enum(["response_item", "event_msg"]),
|
type: z.literal("agent_message"),
|
||||||
payload: z.unknown().optional(),
|
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>;
|
const RolloutEventUserMessagePayloadSchema = z.object({
|
||||||
type RolloutResponsePayload = z.infer<typeof RolloutResponsePayloadSchema>;
|
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 {
|
type RolloutResponseReasoningPayload = z.infer<
|
||||||
if (!line) {
|
typeof RolloutResponseReasoningPayloadSchema
|
||||||
return null;
|
>;
|
||||||
}
|
type ParsedRolloutRecord =
|
||||||
try {
|
| { kind: "timeline"; item: AgentTimelineItem }
|
||||||
const parsed = JSON.parse(line);
|
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||||
const result = RolloutEntrySchema.safeParse(parsed);
|
| { kind: "output"; callId: string; output: string }
|
||||||
if (result.success) {
|
| { kind: "ignore" };
|
||||||
return result.data;
|
|
||||||
}
|
const RolloutMessageContentSchema = z
|
||||||
if (
|
.union([
|
||||||
parsed &&
|
z.string().transform((content) => content.trim()),
|
||||||
typeof parsed === "object" &&
|
z.array(
|
||||||
typeof (parsed as { output?: unknown }).output === "string"
|
z
|
||||||
) {
|
.object({
|
||||||
return parseRolloutEntryFromLine((parsed as { output: string }).output);
|
text: z.string().optional(),
|
||||||
}
|
message: z.string().optional(),
|
||||||
} catch {
|
})
|
||||||
return null;
|
.passthrough()
|
||||||
}
|
).transform((content) =>
|
||||||
return null;
|
content
|
||||||
}
|
.map((block) => block.text ?? block.message ?? "")
|
||||||
|
.map((text) => text.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
function extractMessageText(content: unknown): string {
|
function extractMessageText(content: unknown): string {
|
||||||
if (!Array.isArray(content)) {
|
const parsed = RolloutMessageContentSchema.safeParse(content);
|
||||||
|
if (!parsed.success) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
return parsed.data;
|
||||||
for (const block of content) {
|
}
|
||||||
if (!block || typeof block !== "object") {
|
|
||||||
continue;
|
const RolloutEventMessageTextSchema = z
|
||||||
}
|
.union([
|
||||||
const record = block as Record<string, unknown>;
|
z.string(),
|
||||||
const text = typeof record.text === "string" ? record.text : undefined;
|
z
|
||||||
if (text && text.trim()) {
|
.object({
|
||||||
parts.push(text.trim());
|
message: z.string().optional(),
|
||||||
continue;
|
text: z.string().optional(),
|
||||||
}
|
})
|
||||||
const message =
|
.passthrough()
|
||||||
typeof record.message === "string" ? record.message : undefined;
|
.transform((message) => message.message ?? message.text ?? ""),
|
||||||
if (message && message.trim()) {
|
]);
|
||||||
parts.push(message.trim());
|
|
||||||
}
|
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 {
|
function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||||
@@ -5207,10 +5271,10 @@ function isSyntheticRolloutUserMessage(text: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractReasoningText(payload: RolloutResponsePayload): string {
|
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
|
||||||
if (Array.isArray(payload?.summary)) {
|
if (Array.isArray(payload.summary)) {
|
||||||
const text = payload.summary
|
const text = payload.summary
|
||||||
.map((item) => (item && typeof item.text === "string" ? item.text : ""))
|
.map((item) => item.text ?? "")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim();
|
.trim();
|
||||||
@@ -5218,17 +5282,117 @@ function extractReasoningText(payload: RolloutResponsePayload): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle content array with reasoning_text items
|
|
||||||
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
||||||
if (contentText) {
|
if (contentText) {
|
||||||
return contentText;
|
return contentText;
|
||||||
}
|
}
|
||||||
if (typeof payload?.text === "string") {
|
if (typeof payload.text === "string") {
|
||||||
return payload.text;
|
return payload.text;
|
||||||
}
|
}
|
||||||
return "";
|
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(
|
function parseJsonRolloutTimeline(
|
||||||
parsed: unknown
|
parsed: unknown
|
||||||
): AgentTimelineItem[] | null {
|
): AgentTimelineItem[] | null {
|
||||||
@@ -5244,25 +5408,28 @@ function parseJsonRolloutTimeline(
|
|||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const record = entry as Record<string, unknown>;
|
const messagePayloadResult =
|
||||||
const type = record.type;
|
RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||||
if (type === "message") {
|
if (messagePayloadResult.success) {
|
||||||
const role = record.role;
|
const payload = messagePayloadResult.data;
|
||||||
const text = extractMessageText(record.content);
|
const text = extractMessageText(payload.content);
|
||||||
if (!text || typeof role !== "string") {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (role === "assistant") {
|
if (payload.role === "assistant") {
|
||||||
timeline.push({ type: "assistant_message", text });
|
timeline.push({ type: "assistant_message", text });
|
||||||
} else if (role === "user") {
|
} else if (payload.role === "user") {
|
||||||
if (!isSyntheticRolloutUserMessage(text)) {
|
if (!isSyntheticRolloutUserMessage(text)) {
|
||||||
timeline.push({ type: "user_message", text });
|
timeline.push({ type: "user_message", text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
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) {
|
if (text) {
|
||||||
timeline.push({ type: "reasoning", text });
|
timeline.push({ type: "reasoning", text });
|
||||||
}
|
}
|
||||||
@@ -5294,126 +5461,39 @@ export async function parseRolloutFile(
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// First pass: collect function_call_output entries by call_id
|
const parsedRecords = lines
|
||||||
const outputsByCallId = new Map<string, string>();
|
.map((line) => {
|
||||||
for (const line of lines) {
|
try {
|
||||||
const entry = parseRolloutEntryFromLine(line);
|
return JSON.parse(line);
|
||||||
if (!entry || entry.type !== "response_item") continue;
|
} catch {
|
||||||
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
|
return null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
} else if (entry.type === "event_msg") {
|
})
|
||||||
const payloadResult = RolloutEventPayloadSchema.safeParse(entry.payload);
|
.filter((record): record is unknown => record !== null)
|
||||||
if (!payloadResult.success) continue;
|
.map((record) => RolloutRecordSchema.safeParse(record))
|
||||||
const payload = payloadResult.data;
|
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
|
||||||
|
.map((result) => result.data);
|
||||||
|
|
||||||
if (payload.type === "agent_reasoning" && payload.text) {
|
const outputsByCallId = parsedRecords
|
||||||
timeline.push({ type: "reasoning", text: payload.text });
|
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||||
} else if (payload.type === "agent_message" && payload.message) {
|
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>());
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = {
|
type CodexPersistedTimelineOptions = {
|
||||||
|
|||||||
@@ -216,6 +216,79 @@ describe("codex rollout parsing", () => {
|
|||||||
text: "Let me think about this.",
|
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", () => {
|
describe("complex conversation", () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async function main() {
|
|||||||
if (process.argv.includes("--no-mcp")) {
|
if (process.argv.includes("--no-mcp")) {
|
||||||
config.mcpEnabled = false;
|
config.mcpEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemon = await createPaseoDaemon(config, logger);
|
const daemon = await createPaseoDaemon(config, logger);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -585,6 +585,24 @@ export class Session {
|
|||||||
return;
|
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 = {
|
const payload = {
|
||||||
agentId: event.agentId,
|
agentId: event.agentId,
|
||||||
event: serializeAgentStreamEvent(event.event),
|
event: serializeAgentStreamEvent(event.event),
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ function buildPersistence(
|
|||||||
|
|
||||||
function buildToolCallForPrompt(provider: string, prompt: string) {
|
function buildToolCallForPrompt(provider: string, prompt: string) {
|
||||||
const text = prompt.toLowerCase();
|
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 (provider === "claude") {
|
||||||
if (text.includes("read") && text.includes("/etc/hosts")) {
|
if (text.includes("read") && text.includes("/etc/hosts")) {
|
||||||
return { name: "Read", input: { path: "/etc/hosts" }, output: undefined };
|
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: "default", label: "Default", description: "Ask for permissions" },
|
||||||
{ id: "full-access", label: "Full access", description: "No prompts" },
|
{ id: "full-access", label: "Full access", description: "No prompts" },
|
||||||
{ id: "auto", label: "Auto", description: "Ask/allow based on policy" },
|
{ 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 {
|
private buildAssistantText(prompt: string): string {
|
||||||
const lower = prompt.toLowerCase();
|
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("state saved")) return "state saved";
|
||||||
if (lower.includes("timeline test")) return "timeline test";
|
if (lower.includes("timeline test")) return "timeline test";
|
||||||
if (lower.includes("quick brown fox") && lower.includes("lazy dog")) {
|
if (lower.includes("quick brown fox") && lower.includes("lazy dog")) {
|
||||||
@@ -499,6 +533,9 @@ class FakeAgentSession implements AgentSession {
|
|||||||
prompt: string
|
prompt: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const lower = prompt.toLowerCase();
|
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") {
|
if (toolName === "Read" || toolName === "read_file") {
|
||||||
const p = typeof toolInput.path === "string" ? toolInput.path : "/etc/hosts";
|
const p = typeof toolInput.path === "string" ? toolInput.path : "/etc/hosts";
|
||||||
@@ -512,6 +549,17 @@ class FakeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (toolName === "Bash" || toolName === "shell") {
|
if (toolName === "Bash" || toolName === "shell") {
|
||||||
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
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")) {
|
if (lower.includes("rm -f permission.txt") || command.includes("rm -f permission.txt")) {
|
||||||
const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt");
|
const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt");
|
||||||
try {
|
try {
|
||||||
@@ -660,9 +708,18 @@ class FakeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
return [
|
if (this.provider === "claude") {
|
||||||
{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true },
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
|
|||||||
@@ -98,11 +98,11 @@ function Hero() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-3xl md:text-5xl font-medium tracking-tight">
|
<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>
|
</h1>
|
||||||
<p className="text-white/70 text-lg leading-relaxed">
|
<p className="text-white/70 text-lg leading-relaxed">
|
||||||
Agents run on your machine with your full dev environment. Connect from
|
Use your full dev environment on your own machine. Use voice to check
|
||||||
phone, desktop, or web.
|
in, reply, and keep work moving from your phone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -337,4 +337,3 @@ function FAQItem({
|
|||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user