feat: add keyboard navigation to combobox and select fields on web

This commit is contained in:
Mohamed Boudra
2026-01-30 22:52:07 +07:00
parent 025a68c3a4
commit ea33d1980f
8 changed files with 320 additions and 22 deletions

View File

@@ -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 (
<View style={styles.selectFieldContainer}>
<Pressable
ref={controlRef}
onPress={onPress}
// @ts-ignore - tabIndex is web-only
tabIndex={0}
accessibilityRole="button"
// @ts-ignore - onKeyDown is web-only
onKeyDown={handleKeyDown}
disabled={disabled}
testID={testID}
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
@@ -332,10 +365,43 @@ function CompactSelectField({
isLoading,
controlRef,
}: CompactSelectFieldProps): 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 (
<Pressable
ref={controlRef}
onPress={onPress}
// @ts-ignore - tabIndex is web-only
tabIndex={0}
accessibilityRole="button"
// @ts-ignore - onKeyDown is web-only
onKeyDown={handleKeyDown}
disabled={disabled}
style={[styles.compactSelectControl, disabled && styles.compactSelectControlDisabled]}
>

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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,
]}
>
<View style={styles.comboboxItemCheckSlot}>
@@ -174,6 +178,7 @@ export function Combobox({
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-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 = (
<SearchInput
placeholder={searchPlaceholder ?? placeholder}
@@ -331,26 +437,20 @@ export function Combobox({
const optionsList = (
<>
{showCustomOption ? (
<ComboboxItem
label={`${customValuePrefix} "${sanitizedSearchValue}"`}
description={customValueDescription}
onPress={() => handleSelect(sanitizedSearchValue)}
/>
) : null}
{hasMatches ? (
filteredOptions.map((opt) => (
{visibleOptions.length > 0 ? (
visibleOptions.map((opt, index) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
))
) : !showCustomOption ? (
) : (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
) : 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",

View File

@@ -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) {

View File

@@ -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-"));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,11 +1,4 @@
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_634_358)">
<rect width="700" height="700" rx="100" fill="#20744A"/>
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_634_358">
<rect width="700" height="700" rx="100" fill="white"/>
</clipPath>
</defs>
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="700" height="700" rx="156" fill="black"/>
<path transform="translate(350,350) scale(1.05) translate(-350,-350)" d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB