Merge origin/main into local-streaming-speech-tts
This commit is contained in:
@@ -3,6 +3,7 @@ import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
@@ -12,9 +13,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
// Wait for the agent to finish responding
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
@@ -28,7 +26,7 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available");
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
@@ -37,4 +35,3 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
allowPermission,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
waitForPermissionPrompt,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
@@ -21,22 +20,30 @@ function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
function getChangesActionLabel(page: Page, label: string) {
|
||||
return getChangesScope(page).getByText(label, { exact: true });
|
||||
}
|
||||
|
||||
function getChangesActionButton(page: Page, label: string) {
|
||||
return getChangesActionLabel(page, label).locator('..');
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
const scope = getChangesScope(page);
|
||||
await scope.getByTestId('changes-view-selector').click();
|
||||
if (view === 'working') {
|
||||
await page.getByTestId('changes-mode-uncommitted').click();
|
||||
} else {
|
||||
await page.getByTestId('changes-mode-base').click();
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesOverflowMenu(page: Page) {
|
||||
@@ -45,15 +52,13 @@ async function openChangesOverflowMenu(page: Page) {
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
async function openChangesShipMenu(page: Page) {
|
||||
const backdrop = page.getByRole('button', { name: 'Ship menu backdrop' }).first();
|
||||
if (await backdrop.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-ship-caret').first();
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
@@ -61,15 +66,23 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
await page.getByText('Changes', { exact: true }).click();
|
||||
const changesTab = explorerHeader.getByText('Changes', { exact: true });
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
await changesTab.click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText('View Changes', { exact: true }).click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
@@ -142,10 +155,24 @@ async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
}, { timeout: 10000 }).toBeTruthy();
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const scope = sheetVisible ? sheet : page;
|
||||
const option = scope.getByText(branchName, { exact: true }).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
const preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||
if (await preferredOption.isVisible().catch(() => false)) {
|
||||
await preferredOption.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
|
||||
const optionCount = await options.count();
|
||||
if (optionCount === 0) {
|
||||
throw new Error(`No worktree options were available in the attach picker`);
|
||||
}
|
||||
const fallbackOption = options.first();
|
||||
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
|
||||
await fallbackOption.click();
|
||||
if (fallbackLabel.length > 0) {
|
||||
await expect(picker).toContainText(fallbackLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
@@ -194,10 +221,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
await waitForAssistantText(page, 'READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim();
|
||||
expect(branchName.length).toBeGreaterThan(0);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const firstCwd = await requestCwd(page);
|
||||
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
@@ -206,16 +242,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
if (repo.name) {
|
||||
expect(normalizedCwd.includes(path.join('worktrees', repo.name))).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, branchName);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAssistantText(page, 'READY2');
|
||||
|
||||
@@ -234,21 +267,50 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-action-commit').click();
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
@@ -258,17 +320,31 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-action-commit').click();
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
@@ -278,50 +354,73 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openChangesShipMenu(page);
|
||||
await page.getByTestId('changes-ship-create-pr').click();
|
||||
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
|
||||
timeout: 60000,
|
||||
});
|
||||
await openChangesShipMenu(page);
|
||||
await expect(page.getByTestId('changes-ship-open-pr')).toBeVisible();
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await page.getByTestId('changes-ship-merge').click();
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
|
||||
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByTestId('changes-ship-caret')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
await openChangesOverflowMenu(page);
|
||||
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive').click();
|
||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||
// Archiving a worktree deletes agents and redirects to home
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
// Repo inspection is async; wait until git options are interactive again.
|
||||
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-picker').click();
|
||||
await expect(page.getByText(branchName, { exact: true })).toHaveCount(0);
|
||||
const attachSheetBackdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' });
|
||||
if (await attachSheetBackdrop.isVisible()) {
|
||||
await attachSheetBackdrop.click({ force: true });
|
||||
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
|
||||
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
if (await attachSheet.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('dropdown-sheet-close').click({ force: true });
|
||||
await expect(attachSheet).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, nonGitDir);
|
||||
const attachPicker = page.getByTestId('worktree-attach-picker');
|
||||
if (await attachPicker.isVisible()) {
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(attachPicker).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
|
||||
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||
await waitForAssistantText(page, 'NON-GIT');
|
||||
await openChangesPanel(page, { expectGit: false });
|
||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-toolbar')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -18,7 +18,7 @@ test('create agent in a temp repo', async ({ page }) => {
|
||||
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||
await page.getByTestId('agent-overflow-menu').click();
|
||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/haiku/i)).toBeVisible();
|
||||
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
|
||||
|
||||
// Wait for agent response containing "Hello" within an assistant message
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
||||
|
||||
@@ -3,13 +3,17 @@ import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId('daemon-card-e2e-test-daemon').getByText('Online', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
async function longPress(page: Page, locator: Locator, durationMs = 1100) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Expected long-press target to have a bounding box.');
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(durationMs);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test('deleting an agent via long-press persists after reload', async ({ page }) => {
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `delete-agent-persists-${nonce}`;
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
@@ -33,6 +18,10 @@ test('deleting an agent via long-press persists after reload', async ({ page })
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
@@ -47,12 +36,11 @@ test('deleting an agent via long-press persists after reload', async ({ page })
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await longPress(page, agentRow, 1200);
|
||||
|
||||
const archiveButton = page.getByTestId('agent-action-archive').first();
|
||||
await expect(archiveButton).toBeVisible({ timeout: 10000 });
|
||||
await archiveButton.click({ force: true });
|
||||
await expect(page.getByTestId('agent-action-cancel')).toHaveCount(0, { timeout: 10000 });
|
||||
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
@@ -137,10 +137,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const transcriptLocator = page.getByText(/voice note/i);
|
||||
await expect(transcriptLocator).toBeVisible({ timeout: 60_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ test('keeps file header sticky while scrolling within a long diff', async ({ pag
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await expect(page.getByText('READY', { exact: true })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -72,6 +74,12 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const relayPort = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
|
||||
@@ -425,18 +425,18 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByText('How would you like to proceed?').first();
|
||||
const promptText = page.getByTestId('permission-request-question').first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const allowButton = page.getByText('Allow', { exact: true }).first();
|
||||
await expect(allowButton).toBeVisible({ timeout: 5000 });
|
||||
await allowButton.click();
|
||||
const acceptButton = page.getByTestId('permission-request-accept').first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByText('Deny', { exact: true }).first();
|
||||
const denyButton = page.getByTestId('permission-request-deny').first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
type TempRepo = {
|
||||
path: string;
|
||||
owner?: string;
|
||||
name?: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -15,10 +13,6 @@ export const createTempGitRepo = async (
|
||||
options?: { withRemote?: boolean }
|
||||
): Promise<TempRepo> => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
const repoName = `paseo-e2e-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
let owner: string | undefined;
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
@@ -29,33 +23,17 @@ export const createTempGitRepo = async (
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||
|
||||
if (withRemote) {
|
||||
try {
|
||||
owner = execSync('gh api user -q .login', { encoding: 'utf8' }).trim();
|
||||
execSync(
|
||||
`gh repo create ${repoName} --private --confirm --source=. --remote=origin --push`,
|
||||
{
|
||||
cwd: repoPath,
|
||||
stdio: 'ignore',
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, 'remote.git');
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
||||
}
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
owner,
|
||||
name: repoName,
|
||||
cleanup: async () => {
|
||||
if (owner && withRemote) {
|
||||
try {
|
||||
execSync(`gh repo delete ${owner}/${repoName} --yes`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,13 @@ import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
@@ -18,12 +22,21 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('My Host').fill('E2E Host');
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect & Save', { exact: true }).click();
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText('E2E Host', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
@@ -78,11 +78,10 @@ test('host removal removes the host from UI and persists after reload', async ({
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const removeButtons = page.getByText('Remove', { exact: true });
|
||||
await expect(removeButtons).toHaveCount(2);
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await removeButtons.nth(1).click();
|
||||
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -17,14 +17,18 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByText('+ Add host', { exact: true }).click();
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect & Save', { exact: true }).click();
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
await expect(nameModal).toBeVisible({ timeout: 15000 });
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
@@ -7,19 +7,26 @@ import {
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
getToolCallCount,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
function buildWriteCommand(filePath: string): string {
|
||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
||||
}
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
@@ -31,11 +38,6 @@ test.describe('permission prompts', () => {
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
// Check tool call count before allowing permission
|
||||
// In "Always Ask" mode, we should see the permission prompt badge
|
||||
const toolCallCountBefore = await getToolCallCount(page);
|
||||
expect(toolCallCountBefore).toBe(1);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
@@ -59,7 +61,12 @@ test.describe('permission prompts', () => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
@@ -71,23 +78,14 @@ test.describe('permission prompts', () => {
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
// Check tool call count before denying permission
|
||||
// In "Always Ask" mode, we should see the permission prompt badge
|
||||
const toolCallCountBefore = await getToolCallCount(page);
|
||||
expect(toolCallCountBefore).toBe(1);
|
||||
|
||||
await denyPermission(page);
|
||||
|
||||
// After denying permission, wait for the agent to show the permission denied result
|
||||
// The agent might stay in running state but should show a tool call result
|
||||
await page.waitForTimeout(3000); // Give time for the denial to be processed
|
||||
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
// After denying, the tool call count should still be 1
|
||||
// The UI doesn't show a separate badge for denied permissions
|
||||
const toolCallCountAfter = await getToolCallCount(page);
|
||||
expect(toolCallCountAfter).toBe(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { darkTheme } from "@/styles/theme";
|
||||
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
@@ -37,6 +37,7 @@ import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -98,21 +99,6 @@ function PushNotificationRouter() {
|
||||
}
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,11 @@ export function DropdownSheet({
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
@@ -267,6 +272,15 @@ export function DropdownSheet({
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sheet"
|
||||
onPress={handleClose}
|
||||
hitSlop={10}
|
||||
testID="dropdown-sheet-close"
|
||||
>
|
||||
<X size={18} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
@@ -990,11 +1004,12 @@ export function GitOptionsSection({
|
||||
visible={isWorktreeSheetOpen}
|
||||
onClose={() => setIsWorktreeSheetOpen(false)}
|
||||
>
|
||||
{worktreeOptions.map((option) => (
|
||||
{worktreeOptions.map((option, index) => (
|
||||
<SelectOption
|
||||
key={option.path}
|
||||
label={option.label}
|
||||
selected={option.path === selectedWorktreePath}
|
||||
testID={`worktree-attach-option-${index}`}
|
||||
onPress={() => {
|
||||
onSelectWorktreePath(option.path);
|
||||
setIsWorktreeSheetOpen(false);
|
||||
@@ -1094,6 +1109,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -84,6 +84,11 @@ export interface AgentStreamViewProps {
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
/**
|
||||
* True when we expect a history snapshot to arrive shortly (e.g. after opening an agent
|
||||
* or resuming after connectivity changes) and should avoid showing the "empty" state.
|
||||
*/
|
||||
isSyncingHistory?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStreamView({
|
||||
@@ -92,6 +97,7 @@ export function AgentStreamView({
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
isSyncingHistory = false,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -590,6 +596,43 @@ export function AgentStreamView({
|
||||
return { marginBottom: tightGap };
|
||||
}, [listHeaderComponent, tightGap]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = (streamHead?.length ?? 0) > 0;
|
||||
if (hasPermissions || hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowSyncing =
|
||||
isSyncingHistory || agent.status === "running";
|
||||
|
||||
if (shouldShowSyncing) {
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
isSyncingHistory,
|
||||
pendingPermissionItems.length,
|
||||
streamHead,
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
@@ -607,13 +650,7 @@ export function AgentStreamView({
|
||||
style={stylesheet.list}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={
|
||||
@@ -1071,6 +1108,7 @@ function PermissionRequestCard({
|
||||
) : null}
|
||||
|
||||
<Text
|
||||
testID="permission-request-question"
|
||||
style={[
|
||||
permissionStyles.question,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
@@ -1086,6 +1124,7 @@ function PermissionRequestCard({
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={(state) => {
|
||||
const hovered = Boolean((state as any).hovered);
|
||||
const pressed = Boolean(state.pressed);
|
||||
@@ -1126,6 +1165,7 @@ function PermissionRequestCard({
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={(state) => {
|
||||
const hovered = Boolean((state as any).hovered);
|
||||
const pressed = Boolean(state.pressed);
|
||||
|
||||
@@ -263,37 +263,33 @@ function SidebarContent({
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
|
||||
// If not a git repo, only show files tab
|
||||
const effectiveTab = isGit ? activeTab : "files";
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={styles.header} testID="explorer-header">
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit ? (
|
||||
<Pressable
|
||||
style={[styles.tab, effectiveTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
effectiveTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={[styles.tab, effectiveTab === "files" && styles.tabActive]}
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
!isGit && styles.tabTextMuted,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
effectiveTab === "files" && styles.tabTextActive,
|
||||
activeTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Files
|
||||
@@ -311,10 +307,10 @@ function SidebarContent({
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{effectiveTab === "changes" && (
|
||||
{activeTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
)}
|
||||
{effectiveTab === "files" && (
|
||||
{activeTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
</View>
|
||||
@@ -389,6 +385,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabTextMuted: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
headerRightSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
MoreVertical,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import type { ExplorerEntry, ExplorerFile } from "@/stores/session-store";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
@@ -312,6 +316,11 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
const showInitialLoading =
|
||||
!directories.has(".") &&
|
||||
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
||||
const showBackFromError = Boolean(error && selectedEntryPath);
|
||||
const errorRecoveryPath = useMemo(
|
||||
() => getErrorRecoveryPath(explorerState),
|
||||
[explorerState]
|
||||
);
|
||||
|
||||
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
||||
const minTreeWidth = 220;
|
||||
@@ -475,6 +484,17 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBackFromError = useCallback(() => {
|
||||
if (!agentId || !requestDirectoryListing) {
|
||||
return;
|
||||
}
|
||||
selectExplorerEntry(agentId, null);
|
||||
requestDirectoryListing(agentId, errorRecoveryPath, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: true,
|
||||
});
|
||||
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
|
||||
|
||||
if (!agentExists) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
@@ -491,19 +511,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
if (agentId) {
|
||||
requestDirectoryListing(agentId, ".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
<View style={styles.errorActions}>
|
||||
{showBackFromError ? (
|
||||
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||
<Text style={styles.retryButtonText}>Back</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
if (agentId) {
|
||||
requestDirectoryListing(agentId, ".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : showInitialLoading ? (
|
||||
<View style={styles.centerState}>
|
||||
@@ -960,6 +987,21 @@ function getAncestorDirectories(directory: string): string[] {
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||
if (!state) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
const currentHistoryPath =
|
||||
state.history.length > 0 ? state.history[state.history.length - 1] : null;
|
||||
const candidate = currentHistoryPath ?? state.lastVisitedPath ?? state.currentPath;
|
||||
|
||||
if (!candidate || candidate.length === 0) {
|
||||
return ".";
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -1065,6 +1107,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
errorActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
View,
|
||||
@@ -17,8 +16,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Linking from "expo-linking";
|
||||
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
@@ -40,20 +38,6 @@ import {
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// =============================================================================
|
||||
// Action Status Hook
|
||||
// =============================================================================
|
||||
// Tracks mutation state with a brief success phase before returning to idle.
|
||||
// State flow: idle → pending → success (1s) → idle
|
||||
// =============================================================================
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
type ActionState = {
|
||||
status: ActionStatus;
|
||||
trigger: () => void;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Git Actions Data Structure
|
||||
// =============================================================================
|
||||
@@ -85,47 +69,6 @@ interface GitActions {
|
||||
menu: GitAction[];
|
||||
}
|
||||
|
||||
function useActionStatus<TData, TError, TVariables, TContext>(
|
||||
mutation: UseMutationResult<TData, TError, TVariables, TContext>,
|
||||
onTrigger?: () => void
|
||||
): ActionState {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Watch for mutation success to trigger success display
|
||||
useEffect(() => {
|
||||
if (mutation.isSuccess && !mutation.isPending) {
|
||||
setShowSuccess(true);
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
mutation.reset();
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
}
|
||||
}, [mutation.isSuccess, mutation.isPending, mutation]);
|
||||
|
||||
const status: ActionStatus = mutation.isPending
|
||||
? "pending"
|
||||
: showSuccess
|
||||
? "success"
|
||||
: "idle";
|
||||
|
||||
const trigger = useCallback(() => {
|
||||
onTrigger?.();
|
||||
mutation.mutate(undefined as TVariables);
|
||||
}, [mutation, onTrigger]);
|
||||
|
||||
return { status, trigger };
|
||||
}
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
@@ -480,10 +423,6 @@ type GitDiffSection = {
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
@@ -669,157 +608,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutCommit(cwd, { addAll: true });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "push" })
|
||||
);
|
||||
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "create-pr" })
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
|
||||
);
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runCommit({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [runCommit, serverId, cwd]);
|
||||
|
||||
const prMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutPrCreate(cwd, {});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void refreshPrStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutMerge(cwd, {
|
||||
baseRef,
|
||||
strategy: "merge",
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const mergeFromBaseMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutMergeFromBase(cwd, {
|
||||
baseRef,
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const pushMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutPush(cwd);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const handlePush = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runPush({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to push";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [runPush, serverId, cwd]);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
throw new Error("Worktree path unavailable");
|
||||
}
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
router.replace("/agent" as any);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
setActionError(null);
|
||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
||||
|
||||
// Wrap mutations with action status for UI feedback
|
||||
const commitAction = useActionStatus(commitMutation);
|
||||
const prCreateAction = useActionStatus(prMutation, () => void persistShipDefault("pr"));
|
||||
const mergeAction = useActionStatus(mergeMutation, () => void persistShipDefault("merge"));
|
||||
const mergeFromBaseAction = useActionStatus(mergeFromBaseMutation);
|
||||
const pushAction = useActionStatus(pushMutation);
|
||||
const archiveAction = useActionStatus(archiveMutation);
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
setActionError("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace("/agent" as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
|
||||
|
||||
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
|
||||
({ item, section }) => (
|
||||
@@ -861,16 +741,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||
}, [baseRef]);
|
||||
const commitDisabled = actionsDisabled || commitMutation.isPending;
|
||||
const prDisabled = actionsDisabled || prMutation.isPending;
|
||||
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges;
|
||||
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false);
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveMutation.isPending ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
|
||||
let bodyContent: ReactElement;
|
||||
@@ -966,8 +847,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitAction.status,
|
||||
handler: commitAction.trigger,
|
||||
status: commitStatus,
|
||||
handler: handleCommit,
|
||||
});
|
||||
|
||||
// Push - when has remote
|
||||
@@ -978,9 +859,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushAction.status,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
handler: pushAction.trigger,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1006,8 +887,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateAction.status,
|
||||
handler: prCreateAction.trigger,
|
||||
status: prCreateStatus,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1019,9 +900,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeAction.status,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
handler: mergeAction.trigger,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1033,9 +914,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseAction.status,
|
||||
status: mergeFromBaseStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
handler: mergeFromBaseAction.trigger,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1047,9 +928,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveAction.status,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: archiveAction.trigger,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1061,7 +942,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
|
||||
else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
}
|
||||
// Rule 3: Has PR → View PR
|
||||
@@ -1103,7 +984,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
]);
|
||||
|
||||
// Helper to get display label based on status
|
||||
@@ -1216,7 +1098,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
|
||||
{isGit ? (
|
||||
<View style={styles.diffStatusContainer}>
|
||||
<View style={styles.diffStatusInner}>
|
||||
<Pressable
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
DraggableList,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "./draggable-list";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -185,6 +185,164 @@ function SectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupedAgentRowProps {
|
||||
agent: AggregatedAgent;
|
||||
isSelected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
onPress: () => void;
|
||||
onLongPress: () => void;
|
||||
onArchive: (e: { stopPropagation: () => void }) => void;
|
||||
}
|
||||
|
||||
function GroupedAgentRow({
|
||||
agent,
|
||||
isSelected,
|
||||
shortcutNumber,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onArchive,
|
||||
}: GroupedAgentRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(hoverOutTimeoutRef.current);
|
||||
hoverOutTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearHoverOutTimeout();
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const handleHoverIn = useCallback(() => {
|
||||
clearHoverOutTimeout();
|
||||
setIsHovered(true);
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const handleHoverOut = useCallback(() => {
|
||||
clearHoverOutTimeout();
|
||||
hoverOutTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
setIsArchiveConfirmVisible(false);
|
||||
}, 50);
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const activeBranchLabel = checkout?.isGit
|
||||
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null) ??
|
||||
checkout.baseRef ??
|
||||
"git")
|
||||
: null;
|
||||
|
||||
const canArchive = agent.status !== "running" && !agent.requiresAttention;
|
||||
const showArchive = canArchive && shortcutNumber === null && (isHovered || isArchiveConfirmVisible);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.agentItem,
|
||||
!isSelected && styles.agentItemUnselected,
|
||||
isSelected && styles.agentItemSelected,
|
||||
isHovered && styles.agentItemHovered,
|
||||
pressed && styles.agentItemPressed,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPressIn={() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
handleHoverIn();
|
||||
}}
|
||||
onLongPress={onLongPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
<View style={styles.agentContent}>
|
||||
<View style={styles.row}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
<Text
|
||||
style={[
|
||||
styles.agentTitle,
|
||||
(isSelected || isHovered) && styles.agentTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New agent"}
|
||||
</Text>
|
||||
{shortcutNumber !== null ? (
|
||||
<View style={styles.branchBadge}>
|
||||
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||
{shortcutNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : showArchive ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.branchBadge,
|
||||
isArchiveConfirmVisible && styles.archiveConfirmBadge,
|
||||
]}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isArchiveConfirmVisible) {
|
||||
setIsArchiveConfirmVisible(true);
|
||||
return;
|
||||
}
|
||||
onArchive(e);
|
||||
setIsArchiveConfirmVisible(false);
|
||||
}}
|
||||
testID={
|
||||
isArchiveConfirmVisible
|
||||
? `agent-archive-confirm-${agent.serverId}-${agent.id}`
|
||||
: `agent-archive-${agent.serverId}-${agent.id}`
|
||||
}
|
||||
>
|
||||
{({ hovered: archiveHovered }) =>
|
||||
isArchiveConfirmVisible ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.branchBadgeText,
|
||||
styles.archiveConfirmText,
|
||||
archiveHovered && styles.archiveConfirmTextHovered,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Confirm
|
||||
</Text>
|
||||
) : (
|
||||
<Archive
|
||||
size={12}
|
||||
color={
|
||||
archiveHovered ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
) : activeBranchLabel ? (
|
||||
<View style={styles.branchBadge}>
|
||||
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||
{activeBranchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupedAgentList({
|
||||
agents,
|
||||
isRefreshing = false,
|
||||
@@ -337,107 +495,6 @@ export function GroupedAgentList({
|
||||
[]
|
||||
);
|
||||
|
||||
const AgentListRow = useCallback(
|
||||
({ agent }: { agent: AggregatedAgent }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const isRunning = agent.status === "running";
|
||||
const shortcutNumber =
|
||||
showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null;
|
||||
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const activeBranchLabel = checkout?.isGit
|
||||
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null) ??
|
||||
checkout.baseRef ??
|
||||
"git")
|
||||
: null;
|
||||
|
||||
const canArchive = !isRunning && !agent.requiresAttention;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.agentItem,
|
||||
!isSelected && styles.agentItemUnselected,
|
||||
isSelected && styles.agentItemSelected,
|
||||
isHovered && styles.agentItemHovered,
|
||||
pressed && styles.agentItemPressed,
|
||||
]}
|
||||
onPress={() => handleAgentPress(agent.serverId, agent.id)}
|
||||
onLongPress={() => handleAgentLongPress(agent)}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
<View style={styles.agentContent}>
|
||||
<View style={styles.row}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
<Text
|
||||
style={[
|
||||
styles.agentTitle,
|
||||
(isSelected || isHovered) && styles.agentTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New agent"}
|
||||
</Text>
|
||||
{shortcutNumber !== null ? (
|
||||
<View style={styles.branchBadge}>
|
||||
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||
{shortcutNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : isHovered && canArchive ? (
|
||||
<Pressable
|
||||
style={styles.branchBadge}
|
||||
onPress={(e) => handleArchiveAgent(e, agent)}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(true)}
|
||||
testID={`agent-archive-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered: archiveHovered }) => (
|
||||
<Archive
|
||||
size={12}
|
||||
color={
|
||||
archiveHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : activeBranchLabel ? (
|
||||
<View style={styles.branchBadge}>
|
||||
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||
{activeBranchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
[
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
handleArchiveAgent,
|
||||
selectedAgentId,
|
||||
showShortcutBadges,
|
||||
shortcutIndexByAgentKey,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
]
|
||||
);
|
||||
|
||||
const renderSection = useCallback(
|
||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
||||
@@ -454,12 +511,34 @@ export function GroupedAgentList({
|
||||
/>
|
||||
{!isCollapsed &&
|
||||
section.agents.map((agent) => (
|
||||
<AgentListRow key={`${agent.serverId}:${agent.id}`} agent={agent} />
|
||||
<GroupedAgentRow
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
agent={agent}
|
||||
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
|
||||
shortcutNumber={
|
||||
showShortcutBadges
|
||||
? (shortcutIndexByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null)
|
||||
: null
|
||||
}
|
||||
onPress={() => handleAgentPress(agent.serverId, agent.id)}
|
||||
onLongPress={() => handleAgentLongPress(agent)}
|
||||
onArchive={(e) => handleArchiveAgent(e, agent)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
handleArchiveAgent,
|
||||
handleCreateAgentInProject,
|
||||
selectedAgentId,
|
||||
showShortcutBadges,
|
||||
shortcutIndexByAgentKey,
|
||||
toggleProjectCollapsed,
|
||||
]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
@@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 12,
|
||||
},
|
||||
archiveConfirmBadge: {
|
||||
minWidth: 76,
|
||||
maxWidth: 9999,
|
||||
flexShrink: 0,
|
||||
},
|
||||
archiveConfirmText: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
archiveConfirmTextHovered: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
agentTitleHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 1,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
export function HeaderToggleButton({
|
||||
onPress,
|
||||
tooltipLabel,
|
||||
tooltipKeys,
|
||||
tooltipSide,
|
||||
tooltipDelayDuration = 0,
|
||||
style,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style" | "onPress"> & {
|
||||
onPress: NonNullable<PressableProps["onPress"]>;
|
||||
tooltipLabel: string;
|
||||
tooltipKeys: ShortcutKey[];
|
||||
tooltipSide: "left" | "right" | "top" | "bottom";
|
||||
tooltipDelayDuration?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const tooltipTestID =
|
||||
typeof props.testID === "string" && props.testID.length > 0
|
||||
? `${props.testID}-tooltip`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
onPress={(e) => {
|
||||
onPress(e);
|
||||
}}
|
||||
style={[styles.button, style]}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
<Shortcut keys={tooltipKeys} style={styles.shortcut} textStyle={styles.shortcutText} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
button: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
shortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
shortcutText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
style={styles.menuButton}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={["mod", "B"]}
|
||||
tooltipSide="right"
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
</HeaderToggleButton>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuButton: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
keys: ShortcutKey[];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type TooltipContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
const TooltipContext = createContext<TooltipContextValue | null>(null);
|
||||
|
||||
function useTooltipContext(componentName: string): TooltipContextValue {
|
||||
const ctx = useContext(TooltipContext);
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within <Tooltip />`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
side: Side;
|
||||
align: Align;
|
||||
offset: number;
|
||||
}): { x: number; y: number; actualSide: Side } {
|
||||
const { width: contentWidth, height: contentHeight } = contentSize;
|
||||
|
||||
const spaceTop = triggerRect.y - displayArea.y;
|
||||
const spaceBottom =
|
||||
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
|
||||
const spaceLeft = triggerRect.x - displayArea.x;
|
||||
const spaceRight =
|
||||
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
|
||||
|
||||
let actualSide = side;
|
||||
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||
actualSide = "top";
|
||||
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||
actualSide = "bottom";
|
||||
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
|
||||
actualSide = "right";
|
||||
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
|
||||
actualSide = "left";
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
if (actualSide === "bottom") {
|
||||
y = triggerRect.y + triggerRect.height + offset;
|
||||
if (align === "start") {
|
||||
x = triggerRect.x;
|
||||
} else if (align === "end") {
|
||||
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||
} else {
|
||||
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||
}
|
||||
} else if (actualSide === "top") {
|
||||
y = triggerRect.y - contentHeight - offset;
|
||||
if (align === "start") {
|
||||
x = triggerRect.x;
|
||||
} else if (align === "end") {
|
||||
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||
} else {
|
||||
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||
}
|
||||
} else if (actualSide === "left") {
|
||||
x = triggerRect.x - contentWidth - offset;
|
||||
if (align === "start") {
|
||||
y = triggerRect.y;
|
||||
} else if (align === "end") {
|
||||
y = triggerRect.y + triggerRect.height - contentHeight;
|
||||
} else {
|
||||
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
|
||||
}
|
||||
} else {
|
||||
x = triggerRect.x + triggerRect.width + offset;
|
||||
if (align === "start") {
|
||||
y = triggerRect.y;
|
||||
} else if (align === "end") {
|
||||
y = triggerRect.y + triggerRect.height - contentHeight;
|
||||
} else {
|
||||
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
const padding = 8;
|
||||
x = Math.max(
|
||||
padding,
|
||||
Math.min(displayArea.width - contentWidth - padding, x)
|
||||
);
|
||||
y = Math.max(
|
||||
displayArea.y + padding,
|
||||
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y)
|
||||
);
|
||||
|
||||
return { x, y, actualSide };
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
delayDuration = 0,
|
||||
enabledOnDesktop = true,
|
||||
enabledOnMobile = false,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
delayDuration?: number;
|
||||
enabledOnDesktop?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
}>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
open: isOpen,
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration]
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
}
|
||||
|
||||
export function TooltipTrigger({
|
||||
children,
|
||||
disabled,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPress,
|
||||
...props
|
||||
}: PropsWithChildren<PressableProps>): ReactElement {
|
||||
const ctx = useTooltipContext("TooltipTrigger");
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearOpenTimer = useCallback(() => {
|
||||
if (openTimerRef.current) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleOpen = useCallback(() => {
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
if (ctx.delayDuration <= 0) {
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
openTimerRef.current = setTimeout(() => {
|
||||
ctx.setOpen(true);
|
||||
openTimerRef.current = null;
|
||||
}, ctx.delayDuration);
|
||||
}, [clearOpenTimer, ctx, disabled]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(false);
|
||||
}, [clearOpenTimer, ctx]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearOpenTimer();
|
||||
};
|
||||
}, [clearOpenTimer]);
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: any) => {
|
||||
onHoverIn?.(e);
|
||||
scheduleOpen();
|
||||
},
|
||||
[onHoverIn, scheduleOpen]
|
||||
);
|
||||
|
||||
const handleHoverOut = useCallback(
|
||||
(e?: any) => {
|
||||
onHoverOut?.(e);
|
||||
close();
|
||||
},
|
||||
[onHoverOut, close]
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onFocus={(e) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
}}
|
||||
onPress={(e) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
}}
|
||||
{...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null)}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function TooltipContent({
|
||||
children,
|
||||
side = "top",
|
||||
align = "center",
|
||||
offset = 6,
|
||||
style,
|
||||
testID,
|
||||
maxWidth = 280,
|
||||
}: PropsWithChildren<{
|
||||
side?: Side;
|
||||
align?: Align;
|
||||
offset?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
maxWidth?: number;
|
||||
}>): ReactElement | null {
|
||||
const ctx = useTooltipContext("TooltipContent");
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBarHeight =
|
||||
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
|
||||
const result = computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x: result.x, y: result.y });
|
||||
}, [triggerRect, contentSize, side, align, offset]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!ctx.open || !ctx.enabled) return null;
|
||||
|
||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (Platform.OS === "web") {
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
{ maxWidth },
|
||||
style,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={ctx.open}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
{ maxWidth },
|
||||
style,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: { flex: 1 },
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
@@ -915,16 +915,6 @@ export function SessionProvider({
|
||||
});
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const currentState = prev.get(agentId);
|
||||
if (currentState === false) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
// on status changes, which is sufficient for sorting and display purposes.
|
||||
@@ -956,7 +946,7 @@ export function SessionProvider({
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -990,16 +980,6 @@ export function SessionProvider({
|
||||
requestId: (message.payload as any).requestId,
|
||||
});
|
||||
}
|
||||
if (status === "agent_initialized" && "agentId" in message.payload) {
|
||||
const agentId = (message.payload as any).agentId as string | undefined;
|
||||
if (agentId) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubPermissionRequest = client.on(
|
||||
@@ -1443,12 +1423,6 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
if (!client) {
|
||||
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
@@ -1470,24 +1444,17 @@ export function SessionProvider({
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
|
||||
[serverId, client, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
({ agentId }: { agentId: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
refreshAgentMutation
|
||||
.mutateAsync({ agentId })
|
||||
.catch((error) => {
|
||||
@@ -1499,13 +1466,7 @@ export function SessionProvider({
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
serverId,
|
||||
refreshAgentMutation,
|
||||
setAgentStreamTail,
|
||||
setInitializingAgents,
|
||||
clearAgentStreamHead,
|
||||
]
|
||||
[serverId, refreshAgentMutation, setInitializingAgents]
|
||||
);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 10000;
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export function useAgentInitialization(serverId: string) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
|
||||
const ensureAgentIsInitialized = useCallback(
|
||||
(agentId: string): Promise<void> => {
|
||||
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
|
||||
}
|
||||
|
||||
const deferred = createInitDeferred(key);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
new Error(
|
||||
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
|
||||
)
|
||||
);
|
||||
}, INIT_TIMEOUT_MS);
|
||||
attachInitTimeout(key, timeoutId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
if (!client) {
|
||||
clearTimeout(timeoutId);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
|
||||
client
|
||||
.initializeAgent(agentId)
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
|
||||
// message, handled in SessionContext.
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
||||
[client, serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
||||
[client, serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
return { ensureAgentIsInitialized, refreshAgent };
|
||||
}
|
||||
|
||||
|
||||
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
appVisibleRef.current = nextState === "active";
|
||||
if (nextState === "active") {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
}
|
||||
// Send immediately on visibility changes so the server can adapt streaming behavior.
|
||||
sendHeartbeat();
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
||||
}, [recordUserActivity, sendHeartbeat]);
|
||||
|
||||
// Track user activity on web for accurate staleness.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
BackHandler,
|
||||
AppState,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
||||
@@ -502,6 +504,46 @@ function AgentScreenContent({
|
||||
});
|
||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
|
||||
|
||||
// When the app comes back to the foreground, re-sync history for the focused agent.
|
||||
// This covers cases where the OS/backgrounding caused us to miss stream events.
|
||||
const lastAppStateRef = useRef(AppState.currentState);
|
||||
const lastResumeSyncAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
const prev = lastAppStateRef.current;
|
||||
lastAppStateRef.current = nextState;
|
||||
|
||||
if (nextState !== "active" || prev === "active") {
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// Avoid accidental double-syncs on rapid transitions.
|
||||
if (now - lastResumeSyncAtRef.current < 2000) {
|
||||
return;
|
||||
}
|
||||
lastResumeSyncAtRef.current = now;
|
||||
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] Agent initialization failed on resume", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
@@ -569,7 +611,17 @@ function AgentScreenContent({
|
||||
title={effectiveAgent.title || "Agent"}
|
||||
rightContent={
|
||||
<View style={styles.headerRightContent}>
|
||||
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
|
||||
<HeaderToggleButton
|
||||
onPress={toggleFileExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.menuButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
>
|
||||
{isMobile ? (
|
||||
checkout?.isGit ? (
|
||||
<GitBranch
|
||||
@@ -600,7 +652,7 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</HeaderToggleButton>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open && agent?.cwd) {
|
||||
@@ -712,28 +764,21 @@ function AgentScreenContent({
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
{isInitializing && !shouldUseOptimisticStream ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
)}
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{!isInitializing && agent && resolvedAgentId && (
|
||||
{agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/stores/checkout-git-actions-store";
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
||||
});
|
||||
|
||||
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
|
||||
const deferred = createDeferred<any>();
|
||||
const client = {
|
||||
checkoutCommit: vi.fn(() => deferred.promise),
|
||||
};
|
||||
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...(state.sessions as any),
|
||||
[serverId]: { client } as any,
|
||||
},
|
||||
}));
|
||||
|
||||
const store = useCheckoutGitActionsStore.getState();
|
||||
|
||||
const first = store.commit({ serverId, cwd });
|
||||
const second = store.commit({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
store.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
).toBe("pending");
|
||||
|
||||
deferred.resolve({});
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { create } from "zustand";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
|
||||
|
||||
export type CheckoutGitAsyncActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "create-pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
|
||||
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
|
||||
return `${serverId}::${cwd}`;
|
||||
}
|
||||
|
||||
function resolveClient(serverId: string) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const client = session?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function setStatus(
|
||||
key: CheckoutKey,
|
||||
actionId: CheckoutGitAsyncActionId,
|
||||
status: CheckoutGitActionStatus
|
||||
) {
|
||||
useCheckoutGitActionsStore.setState((state) => {
|
||||
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
|
||||
if (current === status) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
statusByCheckout: {
|
||||
...state.statusByCheckout,
|
||||
[key]: {
|
||||
...(state.statusByCheckout[key] ?? {}),
|
||||
[actionId]: status,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["checkoutStatus", serverId, cwd],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === "checkoutDiff" &&
|
||||
key[1] === serverId &&
|
||||
key[2] === cwd
|
||||
);
|
||||
},
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === "checkoutPrStatus" &&
|
||||
key[1] === serverId &&
|
||||
key[2] === cwd
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateWorktreeList() {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
|
||||
return `${key}::${actionId}`;
|
||||
}
|
||||
|
||||
interface CheckoutGitActionsStoreState {
|
||||
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
||||
|
||||
getStatus: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
actionId: CheckoutGitAsyncActionId;
|
||||
}) => CheckoutGitActionStatus;
|
||||
|
||||
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
async function runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId,
|
||||
run,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
actionId: CheckoutGitAsyncActionId;
|
||||
run: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const key = checkoutKey(serverId, cwd);
|
||||
const inflightId = inFlightKey(key, actionId);
|
||||
|
||||
const existing = inFlight.get(inflightId);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevTimer = successTimers.get(inflightId);
|
||||
if (prevTimer) {
|
||||
clearTimeout(prevTimer);
|
||||
successTimers.delete(inflightId);
|
||||
}
|
||||
|
||||
setStatus(key, actionId, "pending");
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
await run();
|
||||
invalidateCheckoutGitQueries(serverId, cwd);
|
||||
setStatus(key, actionId, "success");
|
||||
const timer = setTimeout(() => {
|
||||
setStatus(key, actionId, "idle");
|
||||
successTimers.delete(inflightId);
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
successTimers.set(inflightId, timer);
|
||||
} catch (error) {
|
||||
setStatus(key, actionId, "idle");
|
||||
throw error;
|
||||
} finally {
|
||||
inFlight.delete(inflightId);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(inflightId, promise);
|
||||
await promise;
|
||||
}
|
||||
|
||||
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
|
||||
statusByCheckout: {},
|
||||
|
||||
getStatus: ({ serverId, cwd, actionId }) => {
|
||||
const key = checkoutKey(serverId, cwd);
|
||||
return get().statusByCheckout[key]?.[actionId] ?? "idle";
|
||||
},
|
||||
|
||||
commit: async ({ serverId, cwd }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "commit",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutCommit(cwd, { addAll: true });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
push: async ({ serverId, cwd }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "push",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPush(cwd);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
createPr: async ({ serverId, cwd }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "create-pr",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPrCreate(cwd, {});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "merge-branch",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutMerge(cwd, {
|
||||
baseRef,
|
||||
strategy: "merge",
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "merge-from-base",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutMergeFromBase(cwd, {
|
||||
baseRef,
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "archive-worktree",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function __resetCheckoutGitActionsStoreForTests() {
|
||||
for (const timer of successTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
successTimers.clear();
|
||||
inFlight.clear();
|
||||
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
|
||||
}
|
||||
@@ -46,25 +46,11 @@ function createUniqueTimelineId(
|
||||
timestamp: Date
|
||||
): string {
|
||||
const base = createTimelineId(prefix, text, timestamp);
|
||||
let suffixSeed = state.length;
|
||||
let candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
|
||||
// Fast path when the generated id hasn't been used yet
|
||||
const hasCollision = state.some((entry) => entry.id === candidate);
|
||||
if (!hasCollision) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// If the id already exists (e.g. prior items were pruned/replaced),
|
||||
// spin until we find an unused suffix. Building the lookup Set lazily keeps
|
||||
// the common case cheap while still guaranteeing uniqueness.
|
||||
const usedIds = new Set(state.map((entry) => entry.id));
|
||||
while (usedIds.has(candidate)) {
|
||||
suffixSeed += 1;
|
||||
candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
// We only ever append new timeline items, and we incorporate the current
|
||||
// length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
|
||||
// collision scan (important for large hydration snapshots).
|
||||
const suffixSeed = state.length;
|
||||
return `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface DeferredInit {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const initPromises = new Map<string, DeferredInit>();
|
||||
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const deferred: DeferredInit = { promise, resolve, reject };
|
||||
const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
|
||||
initPromises.set(key, deferred);
|
||||
return deferred;
|
||||
}
|
||||
|
||||
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
deferred.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
export function resolveInitDeferred(key: string): void {
|
||||
const deferred = initPromises.get(key);
|
||||
deferred?.resolve();
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.resolve();
|
||||
}
|
||||
|
||||
export function rejectInitDeferred(key: string, error: Error): void {
|
||||
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatShortcut } from "./format-shortcut";
|
||||
|
||||
describe("formatShortcut", () => {
|
||||
it("uses symbols on macOS", () => {
|
||||
expect(formatShortcut(["mod", "B"], "mac")).toBe("⌘B");
|
||||
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
|
||||
});
|
||||
|
||||
it("uses Ctrl+ on non-mac platforms", () => {
|
||||
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ShortcutKey =
|
||||
| "mod"
|
||||
| "shift"
|
||||
| "alt"
|
||||
| "ctrl"
|
||||
| "meta"
|
||||
| string;
|
||||
|
||||
export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
if (!key) return "";
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return key;
|
||||
}
|
||||
|
||||
export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
const normalized = keys.map((k) => (typeof k === "string" ? k : String(k)));
|
||||
|
||||
if (os === "mac") {
|
||||
const order = ["ctrl", "alt", "shift", "mod", "meta"];
|
||||
const symbols: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
ctrl: "⌃",
|
||||
meta: "⌘",
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => symbols[k] ?? "");
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
.join("");
|
||||
return `${mods.join("")}${main}`;
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
ctrl: "Ctrl",
|
||||
meta: "Win",
|
||||
};
|
||||
return normalized
|
||||
.map((k) => labels[k] ?? normalizeKey(k))
|
||||
.filter(Boolean)
|
||||
.join("+");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsTauriMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
const isApple =
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
|
||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user