diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 70a80e696..d9bbc7346 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -134,11 +134,44 @@ export function SelectField({ valueEllipsizeMode, testID, }: SelectFieldProps): ReactElement { + const getWebKey = useCallback((event: unknown): string | null => { + if (!event || typeof event !== "object") return null; + const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; + if (typeof eventWithNative.key === "string") return eventWithNative.key; + const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined; + return typeof nativeEvent?.key === "string" ? nativeEvent.key : null; + }, []); + + const preventWebDefault = useCallback((event: unknown) => { + if (!event || typeof event !== "object") return; + const candidate = event as { preventDefault?: unknown }; + if (typeof candidate.preventDefault === "function") { + candidate.preventDefault(); + } + }, []); + + const handleKeyDown = useCallback( + (event: unknown) => { + if (Platform.OS !== "web") return; + const key = getWebKey(event); + if (key === "Enter" || key === " ") { + preventWebDefault(event); + onPress(); + } + }, + [getWebKey, onPress, preventWebDefault] + ); + return ( { + if (!event || typeof event !== "object") return null; + const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; + if (typeof eventWithNative.key === "string") return eventWithNative.key; + const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined; + return typeof nativeEvent?.key === "string" ? nativeEvent.key : null; + }, []); + + const preventWebDefault = useCallback((event: unknown) => { + if (!event || typeof event !== "object") return; + const candidate = event as { preventDefault?: unknown }; + if (typeof candidate.preventDefault === "function") { + candidate.preventDefault(); + } + }, []); + + const handleKeyDown = useCallback( + (event: unknown) => { + if (Platform.OS !== "web") return; + const key = getWebKey(event); + if (key === "Enter" || key === " ") { + preventWebDefault(event); + onPress(); + } + }, + [getWebKey, onPress, preventWebDefault] + ); + return ( diff --git a/packages/app/src/components/ui/combobox-keyboard.test.ts b/packages/app/src/components/ui/combobox-keyboard.test.ts new file mode 100644 index 000000000..62acc5180 --- /dev/null +++ b/packages/app/src/components/ui/combobox-keyboard.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { getNextActiveIndex } from "./combobox-keyboard"; + +describe("getNextActiveIndex", () => { + it("returns -1 when itemCount is 0", () => { + expect( + getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" }) + ).toBe(-1); + }); + + it("starts at 0 on ArrowDown when no active item", () => { + expect( + getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" }) + ).toBe(0); + }); + + it("starts at last on ArrowUp when no active item", () => { + expect( + getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" }) + ).toBe(2); + }); + + it("wraps around on ArrowDown and ArrowUp", () => { + expect( + getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" }) + ).toBe(0); + expect( + getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" }) + ).toBe(2); + }); +}); + diff --git a/packages/app/src/components/ui/combobox-keyboard.ts b/packages/app/src/components/ui/combobox-keyboard.ts new file mode 100644 index 000000000..6debe2c33 --- /dev/null +++ b/packages/app/src/components/ui/combobox-keyboard.ts @@ -0,0 +1,19 @@ +export function getNextActiveIndex(args: { + currentIndex: number; + itemCount: number; + key: "ArrowDown" | "ArrowUp"; +}): number { + const { currentIndex, itemCount, key } = args; + + if (itemCount <= 0) return -1; + + if (currentIndex < 0) { + return key === "ArrowDown" ? 0 : itemCount - 1; + } + + const normalizedCurrent = currentIndex % itemCount; + return key === "ArrowDown" + ? (normalizedCurrent + 1) % itemCount + : (normalizedCurrent - 1 + itemCount) % itemCount; +} + diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 777964caf..6d529a762 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -27,6 +27,7 @@ import { size as floatingSize, useFloating, } from "@floating-ui/react-native"; +import { getNextActiveIndex } from "./combobox-keyboard"; const IS_WEB = Platform.OS === "web"; @@ -113,6 +114,7 @@ export interface ComboboxItemProps { label: string; description?: string; selected?: boolean; + active?: boolean; onPress: () => void; testID?: string; } @@ -121,6 +123,7 @@ export function ComboboxItem({ label, description, selected, + active, onPress, testID, }: ComboboxItemProps): ReactElement { @@ -132,6 +135,7 @@ export function ComboboxItem({ style={({ pressed }) => [ styles.comboboxItem, pressed && styles.comboboxItemPressed, + active && styles.comboboxItemActive, ]} > @@ -174,6 +178,7 @@ export function Combobox({ const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [referenceWidth, setReferenceWidth] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); @@ -294,7 +299,6 @@ export function Combobox({ ); }, [options, normalizedSearch]); - const hasMatches = filteredOptions.length > 0; const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = allowCustomValue && @@ -305,6 +309,56 @@ export function Combobox({ opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() ); + const visibleOptions = useMemo(() => { + const next: Array<{ + id: string; + label: string; + description?: string; + }> = []; + + if (showCustomOption) { + next.push({ + id: sanitizedSearchValue, + label: `${customValuePrefix} "${sanitizedSearchValue}"`, + description: customValueDescription, + }); + } + + for (const opt of filteredOptions) { + next.push({ + id: opt.id, + label: opt.label, + description: opt.description, + }); + } + + return next; + }, [ + customValueDescription, + customValuePrefix, + filteredOptions, + sanitizedSearchValue, + showCustomOption, + ]); + + useEffect(() => { + if (!isOpen) return; + if (!IS_WEB && isMobile) return; + + if (visibleOptions.length === 0) { + setActiveIndex(-1); + return; + } + + if (normalizedSearch) { + setActiveIndex(0); + return; + } + + const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + }, [isMobile, isOpen, normalizedSearch, value, visibleOptions]); + const handleSelect = useCallback( (id: string) => { onSelect(id); @@ -319,6 +373,58 @@ export function Combobox({ } }, [handleSelect, sanitizedSearchValue, showCustomOption]); + const handleDesktopKey = useCallback( + (key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { + if (!isOpen) return; + if (!IS_WEB && isMobile) return; + + if (key === "ArrowDown" || key === "ArrowUp") { + event?.preventDefault(); + setActiveIndex((currentIndex) => + getNextActiveIndex({ + currentIndex, + itemCount: visibleOptions.length, + key, + }) + ); + return; + } + + if (key === "Enter") { + if (visibleOptions.length === 0) return; + event?.preventDefault(); + const index = + activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0; + handleSelect(visibleOptions[index]!.id); + return; + } + + if (key === "Escape") { + event?.preventDefault(); + handleClose(); + } + }, + [activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions] + ); + + useEffect(() => { + if (!IS_WEB || !isOpen) return; + + const handler = (event: KeyboardEvent) => { + const key = event.key; + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { + return; + } + handleDesktopKey(key, event); + }; + + // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. + window.addEventListener("keydown", handler, true); + return () => { + window.removeEventListener("keydown", handler, true); + }; + }, [handleDesktopKey, isOpen]); + const searchInput = ( - {showCustomOption ? ( - handleSelect(sanitizedSearchValue)} - /> - ) : null} - {hasMatches ? ( - filteredOptions.map((opt) => ( + {visibleOptions.length > 0 ? ( + visibleOptions.map((opt, index) => ( handleSelect(opt.id)} /> )) - ) : !showCustomOption ? ( + ) : ( {emptyText} - ) : null} + )} ); @@ -412,6 +512,7 @@ export function Combobox({ width: referenceWidth ?? undefined, }, floatingStyles, + referenceWidth === null ? { opacity: 0 } : null, typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null, typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null, ]} @@ -475,6 +576,9 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemPressed: { backgroundColor: theme.colors.surface2, }, + comboboxItemActive: { + backgroundColor: theme.colors.surface2, + }, comboboxItemCheckSlot: { width: 16, alignItems: "center", diff --git a/packages/server/src/server/agent/agent-self-id-mcp.ts b/packages/server/src/server/agent/agent-self-id-mcp.ts index 5572b574a..deb39e75a 100644 --- a/packages/server/src/server/agent/agent-self-id-mcp.ts +++ b/packages/server/src/server/agent/agent-self-id-mcp.ts @@ -13,6 +13,7 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { basename } from "path"; import { z } from "zod"; import { ensureValidJson } from "../json-utils.js"; import type { Logger } from "pino"; @@ -25,6 +26,7 @@ import { import { NotGitRepoError, renameCurrentBranch, + getCheckoutStatus, } from "../../utils/checkout-git.js"; export interface AgentSelfIdMcpOptions { @@ -165,6 +167,21 @@ export async function createAgentSelfIdMcpServer( ); } + const status = await getCheckoutStatus(agent.cwd, { + paseoHome: options.paseoHome, + }); + if (!status.isGit || !status.currentBranch) { + throw new AgentSelfIdToolError("NOT_GIT_REPO", "Unable to determine current branch"); + } + + const worktreeDirName = basename(status.repoRoot); + if (status.currentBranch !== worktreeDirName) { + throw new AgentSelfIdToolError( + "NOT_ALLOWED", + "Branch has already been renamed. Use git commands for subsequent renames." + ); + } + childLogger.debug({ branch: name }, "Renaming branch"); const result = await renameCurrentBranch(agent.cwd, name); if (result.currentBranch !== name) { diff --git a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts index a34f08f0d..87f8bdbc2 100644 --- a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts +++ b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts @@ -224,6 +224,72 @@ describe("self-identification MCP tools", () => { } }); + test("set_branch rejects subsequent renames after initial rename", async () => { + const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + const paseoHome = path.join(repoDir, "paseo-home"); + + try { + initGitRepo(repoDir); + const worktree = await createWorktree({ + branchName: "initial-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "initial-branch", + paseoHome, + }); + + const storagePath = path.join(repoDir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => "agent-subsequent-rename", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: worktree.worktreePath, + }); + + const server = await createAgentSelfIdMcpServer({ + agentManager: manager, + paseoHome, + callerAgentId: agent.id, + logger, + }); + const tool = (server as any)._registeredTools["set_branch"]; + + // First rename should succeed + await tool.callback({ name: "first-rename" }); + + const branchAfterFirst = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + expect(branchAfterFirst).toBe("first-rename"); + + // Second rename should fail + await expect(tool.callback({ name: "second-rename" })).rejects.toMatchObject({ + code: "NOT_ALLOWED", + message: expect.stringContaining("already been renamed"), + }); + + // Branch should still be first-rename + const branchAfterSecond = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + expect(branchAfterSecond).toBe("first-rename"); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + test("set_branch rejects non-Paseo checkouts", async () => { const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); diff --git a/packages/website/public/favicon.ico b/packages/website/public/favicon.ico index 170789c6b..936d6640a 100644 Binary files a/packages/website/public/favicon.ico and b/packages/website/public/favicon.ico differ diff --git a/packages/website/public/favicon.svg b/packages/website/public/favicon.svg index 5d1dedaef..0ef308b97 100644 --- a/packages/website/public/favicon.svg +++ b/packages/website/public/favicon.svg @@ -1,11 +1,4 @@ - - - - - - - - - - + + +