diff --git a/packages/app/e2e/create-agent.spec.ts b/packages/app/e2e/create-agent.spec.ts index b5ed821ba..416386f53 100644 --- a/packages/app/e2e/create-agent.spec.ts +++ b/packages/app/e2e/create-agent.spec.ts @@ -15,6 +15,11 @@ test('create agent in a temp repo', async ({ page }) => { // Verify user message is shown in the stream await expect(page.getByText(prompt, { exact: true })).toBeVisible(); + // Verify we used a fast model (do not fall back to a default like Sonnet). + await page.getByTestId('agent-overflow-menu').click(); + await expect(page.getByText('Model', { exact: true })).toBeVisible(); + await expect(page.getByText(/haiku/i)).toBeVisible(); + // Wait for agent response containing "Hello" within an assistant message const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' }); await expect(assistantMessage).toBeVisible({ timeout: 30000 }); diff --git a/packages/app/e2e/daemon-connectivity.spec.ts b/packages/app/e2e/daemon-connectivity.spec.ts index 66f18c67d..bd4b84270 100644 --- a/packages/app/e2e/daemon-connectivity.spec.ts +++ b/packages/app/e2e/daemon-connectivity.spec.ts @@ -2,12 +2,14 @@ import { test, expect } from './fixtures'; import { gotoHome, openSettings } from './helpers/app'; test('daemon is connected in settings', async ({ page }) => { - const daemonPort = process.env.E2E_DAEMON_PORT ?? '6767'; + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } await gotoHome(page); await openSettings(page); - await expect(page.getByText('localhost', { exact: true })).toBeVisible(); await expect(page.getByText(`ws://localhost:${daemonPort}/ws`)).toBeVisible(); await expect(page.getByText('Online', { exact: true })).toBeVisible(); }); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index db869b881..ba60c1ce4 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -3,6 +3,14 @@ import { test, expect, type Page } from '@playwright/test'; const consoleEntries = new WeakMap(); test.beforeEach(async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error( + 'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' + + 'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.' + ); + } + const entries: string[] = []; consoleEntries.set(page, entries); @@ -14,20 +22,36 @@ test.beforeEach(async ({ page }) => { entries.push(`[pageerror] ${error.message}`); }); - // Set up test daemon connection if available - const daemonPort = process.env.E2E_DAEMON_PORT; - if (daemonPort) { - const testDaemon = { - id: 'e2e-test-daemon', - label: 'localhost', - wsUrl: `ws://localhost:${daemonPort}/ws`, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - await page.addInitScript((daemon) => { + const nowIso = new Date().toISOString(); + const testDaemon = { + id: 'e2e-test-daemon', + label: 'localhost', + wsUrl: `ws://localhost:${daemonPort}/ws`, + restUrl: `http://localhost:${daemonPort}/`, + createdAt: nowIso, + updatedAt: nowIso, + }; + + const createAgentPreferences = { + // Ensure create flow never uses a remembered host from the developer's real app. + serverId: testDaemon.id, + // Keep e2e fast/cheap by default. + provider: 'claude', + providerPreferences: { + claude: { model: 'haiku' }, + codex: { model: 'gpt-5.1-codex-mini' }, + }, + }; + + await page.addInitScript( + ({ daemon, preferences }) => { + // Hard-reset anything that could point to a developer's real daemon. localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); - }, testDaemon); - } + localStorage.removeItem('@paseo:settings'); + localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); + }, + { daemon: testDaemon, preferences: createAgentPreferences } + ); }); test.afterEach(async ({ page }, testInfo) => { diff --git a/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts new file mode 100644 index 000000000..ba76cccb5 --- /dev/null +++ b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from './fixtures'; +import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; +import { createTempGitRepo } from './helpers/workspace'; + +test('sidebar New Agent clones the selected agent settings (not last created)', async ({ page }) => { + const repoA = await createTempGitRepo(); + const repoB = await createTempGitRepo(); + + try { + await gotoHome(page); + await ensureHostSelected(page); + + await setWorkingDirectory(page, repoA.path); + await createAgent(page, 'Agent A: respond with exactly A'); + await expect(page).toHaveURL(/\/agent\//); + const urlA = new URL(page.url()); + const agentPathA = urlA.pathname; + + await gotoHome(page); + await ensureHostSelected(page); + await setWorkingDirectory(page, repoB.path); + await createAgent(page, 'Agent B: respond with exactly B'); + await expect(page).toHaveURL(/\/agent\//); + const urlB = new URL(page.url()); + const agentPathB = urlB.pathname; + + expect(agentPathA).not.toEqual(agentPathB); + + // Navigate back to agent A via URL to ensure it's the selected agent. + await page.goto(agentPathA); + await expect(page).toHaveURL(agentPathA); + await expect(page.getByText('Agent A: respond with exactly A', { exact: true })).toBeVisible(); + + // Click sidebar New Agent and assert it clones agent A's directory (not agent B's). + await page.getByTestId('sidebar-new-agent').click(); + await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoA.path))); + await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoB.path))); + await expect(page.getByText(repoA.path, { exact: true })).toBeVisible(); + + // Now navigate to agent B and assert cloning uses repo B. + await page.goto(agentPathB); + await expect(page).toHaveURL(agentPathB); + await expect(page.getByText('Agent B: respond with exactly B', { exact: true })).toBeVisible(); + + await page.getByTestId('sidebar-new-agent').click(); + await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoB.path))); + await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path))); + await expect(page.getByText(repoB.path, { exact: true })).toBeVisible(); + } finally { + await repoA.cleanup(); + await repoB.cleanup(); + } +}); + diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1ae6acd5d..b887782cc 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -217,7 +217,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { // useLocalSearchParams doesn't update when navigating between same-pattern routes const selectedAgentKey = useMemo(() => { // Match /agent/[serverId]/[agentId] pattern - const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)$/); + const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)\/?$/); if (match) { const [, serverId, agentId] = match; return `${serverId}:${agentId}`; diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index bf39ae491..89266b489 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -624,7 +624,7 @@ function AgentScreenContent({ )} - + diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 316c13dd2..0c6cfb25e 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -12,6 +12,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { Plus, Settings } from "lucide-react-native"; import { router } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; +import { useSessionStore } from "@/stores/session-store"; import { AgentList } from "./agent-list"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; @@ -24,6 +25,30 @@ interface SlidingSidebarProps { selectedAgentId?: string; } +function buildCreateAgentRouteFromSelectedAgentKey(selectedAgentKey?: string) { + if (!selectedAgentKey) { + return { pathname: "/" as const, params: {} as Record }; + } + + const [serverId, agentId] = selectedAgentKey.split(":", 2); + if (!serverId || !agentId) { + return { pathname: "/" as const, params: {} as Record }; + } + + const state = useSessionStore.getState(); + const agent = state.sessions[serverId]?.agents.get(agentId); + if (!agent) { + return { pathname: "/" as const, params: {} as Record }; + } + + const params: Record = { serverId: agent.serverId }; + if (agent.provider) params.provider = agent.provider; + if (agent.currentModeId) params.modeId = agent.currentModeId; + if (agent.model) params.model = agent.model; + if (agent.cwd) params.workingDir = agent.cwd; + return { pathname: "/" as const, params }; +} + export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -81,17 +106,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { closeToAgent(); }, [closeToAgent]); + const handleCreateAgent = useCallback(() => { + router.push(buildCreateAgentRouteFromSelectedAgentKey(selectedAgentId)); + }, [selectedAgentId]); // Mobile: close sidebar and navigate const handleCreateAgentMobile = useCallback(() => { closeToAgent(); - router.push("/"); - }, [closeToAgent]); + handleCreateAgent(); + }, [closeToAgent, handleCreateAgent]); // Desktop: just navigate, don't close const handleCreateAgentDesktop = useCallback(() => { - router.push("/"); - }, []); + handleCreateAgent(); + }, [handleCreateAgent]); // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { @@ -211,6 +239,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { styles.newAgentButton, hovered && styles.newAgentButtonHovered, ]} + testID="sidebar-new-agent" onPress={handleCreateAgentMobile} > @@ -262,6 +291,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { styles.newAgentButton, hovered && styles.newAgentButtonHovered, ]} + testID="sidebar-new-agent" onPress={handleCreateAgentDesktop} > diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 3f8a33083..4029ff377 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -163,8 +163,14 @@ function resolveFormState( } else { result.model = ""; } - } else if (providerPrefs?.model && isValidModel(providerPrefs.model)) { - result.model = providerPrefs.model; + } else if (typeof providerPrefs?.model === "string" && providerPrefs.model.length > 0) { + // If models haven't loaded yet, optimistically apply the stored preference. + // We'll validate once models load and clear it if it isn't available. + if (!availableModels || isValidModel(providerPrefs.model)) { + result.model = providerPrefs.model; + } else { + result.model = ""; + } } else { result.model = ""; }