diff --git a/packages/app/e2e/daemon-connectivity.spec.ts b/packages/app/e2e/daemon-connectivity.spec.ts index cf2526751..66f18c67d 100644 --- a/packages/app/e2e/daemon-connectivity.spec.ts +++ b/packages/app/e2e/daemon-connectivity.spec.ts @@ -2,10 +2,12 @@ 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'; + await gotoHome(page); await openSettings(page); await expect(page.getByText('localhost', { exact: true })).toBeVisible(); - await expect(page.getByText('ws://localhost:6767/ws')).toBeVisible(); + await expect(page.getByText(`ws://localhost:${daemonPort}/ws`)).toBeVisible(); await expect(page.getByText('Online', { exact: true })).toBeVisible(); }); diff --git a/packages/app/e2e/dictation-web.spec.ts b/packages/app/e2e/dictation-web.spec.ts new file mode 100644 index 000000000..4d04e263d --- /dev/null +++ b/packages/app/e2e/dictation-web.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from './fixtures'; +import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; +import { createTempGitRepo } from './helpers/workspace'; +import type { Page } from '@playwright/test'; + +function addFakeMicrophone(page: Page) { + return page.addInitScript(() => { + const mic = { + active: 0, + getUserMediaCalls: 0, + stopCalls: 0, + lastRecorder: null as null | { state: string }, + }; + (window as any).__mic = mic; + + const ensureMediaDevices = () => { + const nav = navigator as any; + if (!nav.mediaDevices) { + nav.mediaDevices = {}; + } + if (typeof nav.mediaDevices.getUserMedia !== 'function') { + nav.mediaDevices.getUserMedia = async () => { + mic.getUserMediaCalls += 1; + mic.active += 1; + const track = { + stop: () => { + mic.stopCalls += 1; + mic.active = Math.max(0, mic.active - 1); + }, + }; + return { + getTracks: () => [track], + }; + }; + } + }; + + ensureMediaDevices(); + + class FakeMediaRecorder extends EventTarget { + public static isTypeSupported() { + return true; + } + + public state: 'inactive' | 'recording' = 'inactive'; + public mimeType: string; + public ondataavailable: ((event: { data: Blob }) => void) | null = null; + public onerror: ((event: unknown) => void) | null = null; + + constructor(_stream: unknown, options?: MediaRecorderOptions) { + super(); + this.mimeType = options?.mimeType ?? 'audio/webm'; + mic.lastRecorder = this; + } + + public start() { + this.state = 'recording'; + } + + public stop() { + if (this.state !== 'recording') { + throw new Error('Not recording'); + } + this.state = 'inactive'; + try { + this.ondataavailable?.({ + data: new Blob(['paseo-e2e-audio'], { type: this.mimeType }), + }); + } catch (err) { + this.onerror?.(err); + } + this.dispatchEvent(new Event('stop')); + } + } + + (window as any).MediaRecorder = FakeMediaRecorder; + }); +} + +test('dictation hotkeys do not trigger on background screens', async ({ page }) => { + await addFakeMicrophone(page); + + const repo = await createTempGitRepo(); + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, 'Respond with exactly: Hello'); + + await expect(page).toHaveURL(/\/agent\//); + + await page.keyboard.press('Control+d'); + await page.waitForTimeout(200); + + const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number); + const active = await page.evaluate(() => (window as any).__mic.active as number); + + expect(calls).toBe(1); + expect(active).toBe(1); + + await page.keyboard.press('Escape'); + await expect + .poll(async () => page.evaluate(() => (window as any).__mic.active as number)) + .toBe(0); + } finally { + await repo.cleanup(); + } +}); + +test('cancel stops mic even if recorder is already inactive', async ({ page }) => { + await addFakeMicrophone(page); + + const repo = await createTempGitRepo(); + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, 'Respond with exactly: Hello'); + + await expect(page).toHaveURL(/\/agent\//); + + await page.keyboard.press('Control+d'); + await expect + .poll(async () => page.evaluate(() => (window as any).__mic.active as number)) + .toBe(1); + + await page.evaluate(() => { + const mic = (window as any).__mic as { lastRecorder: null | { state: string } }; + if (mic.lastRecorder) { + mic.lastRecorder.state = 'inactive'; + } + }); + + await page.keyboard.press('Escape'); + await expect + .poll(async () => page.evaluate(() => (window as any).__mic.active as number)) + .toBe(0); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts new file mode 100644 index 000000000..1b9b74eeb --- /dev/null +++ b/packages/app/e2e/host-selection.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from './fixtures'; +import { ensureHostSelected, gotoHome } from './helpers/app'; + +test('new agent auto-selects the previous host', async ({ page }) => { + await gotoHome(page); + await ensureHostSelected(page); + + // Ensure preference is persisted so a full reload can restore it. + await page.waitForFunction(() => { + const stored = localStorage.getItem('@paseo:create-agent-preferences'); + if (!stored) return false; + try { + const parsed = JSON.parse(stored); + return parsed?.serverId === 'e2e-test-daemon'; + } catch { + return false; + } + }); + + await gotoHome(page); + + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); +}); + +test('new agent respects serverId in the URL', async ({ page }) => { + await page.goto('/?serverId=e2e-test-daemon&serverId=e2e-test-daemon'); + await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); + + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); +}); + diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 3ae0c8c91..2a80ae594 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -45,6 +45,14 @@ function getParamValue(value: string | string[] | undefined) { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } + if (Array.isArray(value)) { + for (const entry of value) { + const trimmed = entry.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } return undefined; } diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts new file mode 100644 index 000000000..81478bb70 --- /dev/null +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { __private__ } from "./use-agent-form-state"; + +describe("useAgentFormState", () => { + describe("__private__.combineInitialValues", () => { + it("returns undefined when no initial values and no initial server id", () => { + expect(__private__.combineInitialValues(undefined, null)).toBeUndefined(); + }); + + it("does not inject a null serverId override when initialValues are present but serverId is absent", () => { + const combined = __private__.combineInitialValues({}, null); + expect(combined).toEqual({}); + expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); + }); + + it("injects serverId from options when provided", () => { + expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({ + serverId: "daemon-1", + }); + }); + + it("keeps other initial values without forcing serverId", () => { + const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null); + expect(combined).toEqual({ workingDir: "/repo" }); + expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); + }); + + it("respects an explicit serverId override (including null) over initialServerId", () => { + expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ + serverId: null, + }); + + expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({ + serverId: "daemon-2", + }); + }); + }); +}); + diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 88850ea61..3f8a33083 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -193,6 +193,29 @@ function resolveFormState( return result; } +function combineInitialValues( + initialValues: FormInitialValues | undefined, + initialServerId: string | null +): FormInitialValues | undefined { + const hasExplicitServerId = initialValues?.serverId !== undefined; + const serverIdFromOptions = initialServerId === null ? undefined : initialServerId; + + // If nobody provided initial values or an explicit serverId, let preferences drive defaults. + if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) { + return undefined; + } + + if (hasExplicitServerId) { + return { ...initialValues, serverId: initialValues?.serverId }; + } + + if (serverIdFromOptions !== undefined) { + return { ...initialValues, serverId: serverIdFromOptions }; + } + + return initialValues; +} + export function useAgentFormState( options: UseAgentFormStateOptions = {} ): UseAgentFormStateResult { @@ -251,13 +274,7 @@ export function useAgentFormState( // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { - if (!initialValues && initialServerId === null) { - return undefined; - } - return { - ...initialValues, - serverId: initialValues?.serverId ?? initialServerId, - }; + return combineInitialValues(initialValues, initialServerId); }, [initialValues, initialServerId]); // Resolve form state when data sources change @@ -547,3 +564,8 @@ export function useAgentFormState( // Re-export for backwards compatibility export type CreateAgentInitialValues = FormInitialValues; + +export const __private__ = { + combineInitialValues, + resolveFormState, +}; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 942a8ef71..ace6e0ecb 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig, configDefaults } from "vitest/config"; import path from "path"; export default defineConfig({ test: { environment: "node", - exclude: ["e2e/**"], + exclude: [...configDefaults.exclude, "e2e/**"], /** * Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`. * Vitest's default worker pool uses worker_threads, which intentionally stub that API and