Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/desktop-tauri/src/components/ProviderGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ProviderGrid from "./ProviderGrid";
import { providerPlaceholder } from "../lib/trayProviders";

vi.mock("../hooks/useLocale", () => ({
useLocale: () => ({ t: (key: string) => key, language: "english" }),
}));

const providers = [
providerPlaceholder("codex", "Codex"),
providerPlaceholder("claude", "Claude"),
];

function grid(selectedProviderId: string | null) {
return (
<ProviderGrid
providers={providers}
selectedProviderId={selectedProviderId}
showAsUsed={false}
onSelect={() => {}}
/>
);
}

afterEach(() => {
delete (Element.prototype as Partial<Element>).scrollIntoView;
});

describe("ProviderGrid", () => {
it("scrolls the active item into view when the selection changes", () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
const { rerender } = render(grid(null));
scrollIntoView.mockClear();
rerender(grid("claude"));
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" });
expect(scrollIntoView.mock.contexts[0]).toBe(
document.querySelector(".provider-grid__item--active"),
);
});
});
12 changes: 11 additions & 1 deletion apps/desktop-tauri/src/components/ProviderGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState, type CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { ProviderUsageSnapshot } from "../types/bridge";
import { ProviderIcon } from "./providers/ProviderIcon";
import { getProviderIcon } from "./providers/providerIcons";
Expand Down Expand Up @@ -36,6 +36,15 @@ export default function ProviderGrid({
const [dragId, setDragId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const canReorder = typeof onReorder === "function";
const gridRef = useRef<HTMLDivElement>(null);

// Keep the selected item visible when it changes (keyboard switching in the
// scrollable dense grid); this never expands a collapsed grid.
useEffect(() => {
gridRef.current
?.querySelector(".provider-grid__item--active")
?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [selectedProviderId]);

const applyReorder = (targetId: string) => {
if (!onReorder || !dragId || dragId === targetId) return;
Expand Down Expand Up @@ -85,6 +94,7 @@ export default function ProviderGrid({

return (
<div
ref={gridRef}
className={`provider-grid${densityClass}${showProviderIcons ? "" : " provider-grid--no-icons"}`}
data-provider-count={totalItems}
data-expanded={isExpanded ? "true" : "false"}
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { fireEvent, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys";

const IDS = ["codex", "claude", "gemini"];

function setup(selectedProviderId: string | null = null) {
const onSelect = vi.fn();
const view = renderHook(
(props: { selected: string | null }) =>
useProviderSwitcherKeys({
providerIds: IDS,
selectedProviderId: props.selected,
onSelect,
}),
{ initialProps: { selected: selectedProviderId } },
);
return { onSelect, ...view };
}

afterEach(() => {
document.body.replaceChildren();
});

describe("useProviderSwitcherKeys", () => {
it("moves with Right and Left, wrapping through Overview", () => {
const { onSelect, rerender } = setup(null);
fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" });
expect(onSelect).toHaveBeenLastCalledWith("codex");

rerender({ selected: "gemini" });
fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" });
expect(onSelect).toHaveBeenLastCalledWith(null);

rerender({ selected: null });
fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" });
expect(onSelect).toHaveBeenLastCalledWith("gemini");
});

it("selects the Nth segment with Ctrl+digit, Overview first", () => {
const { onSelect } = setup("codex");
fireEvent.keyDown(window, { key: "1", code: "Digit1", ctrlKey: true });
expect(onSelect).toHaveBeenLastCalledWith(null);
fireEvent.keyDown(window, { key: "3", code: "Digit3", ctrlKey: true });
expect(onSelect).toHaveBeenLastCalledWith("claude");
});

it("prevents default only when it handles the key", () => {
setup(null);
expect(fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" })).toBe(false);
expect(fireEvent.keyDown(window, { key: "9", code: "Digit9", ctrlKey: true })).toBe(true);
expect(fireEvent.keyDown(window, { key: "x", code: "KeyX" })).toBe(true);
});

it("ignores keys typed into inputs and sliders", () => {
const { onSelect } = setup(null);
const input = document.createElement("input");
input.type = "range";
document.body.append(input);
fireEvent.keyDown(input, { key: "ArrowRight", code: "ArrowRight" });
const field = document.createElement("input");
document.body.append(field);
fireEvent.keyDown(field, { key: "ArrowLeft", code: "ArrowLeft" });
expect(onSelect).not.toHaveBeenCalled();
});

it("ignores keys while a grid drag is active", () => {
const { onSelect } = setup(null);
const dragging = document.createElement("button");
dragging.className = "provider-grid__item provider-grid__item--dragging";
document.body.append(dragging);
fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" });
expect(onSelect).not.toHaveBeenCalled();
});

it("ignores events another handler already consumed", () => {
const { onSelect } = setup(null);
const consume = (event: KeyboardEvent) => event.preventDefault();
document.addEventListener("keydown", consume);
fireEvent.keyDown(document.body, { key: "ArrowRight", code: "ArrowRight" });
document.removeEventListener("keydown", consume);
expect(onSelect).not.toHaveBeenCalled();
});

it("stops listening after unmount", () => {
const { onSelect, unmount } = setup(null);
unmount();
fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" });
expect(onSelect).not.toHaveBeenCalled();
});
});
47 changes: 47 additions & 0 deletions apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { matchSwitcherAction, resolveSwitcherTarget } from "../lib/switcherShortcuts";

const EDITABLE_SELECTOR =
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"]';
const DRAGGING_SELECTOR = ".provider-grid__item--dragging";

/**
* Provider-switcher keyboard navigation shared by the tray flyout and the
* pop-out window. `providerIds` must be the list the grid displays, in
* display order. Keys are ignored while focus is in a text field, select or
* slider (the zoom slider uses the arrow keys) or while a grid drag is active.
*/
export function useProviderSwitcherKeys({
providerIds,
selectedProviderId,
onSelect,
}: {
providerIds: readonly string[];
selectedProviderId: string | null;
onSelect: (providerId: string | null) => void;
}) {
const latest = useRef({ providerIds, selectedProviderId, onSelect });
latest.current = { providerIds, selectedProviderId, onSelect };

useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const target = event.target;
if (target instanceof Element && target.closest(EDITABLE_SELECTOR)) return;
if (document.querySelector(DRAGGING_SELECTOR)) return;
const current = latest.current;
const action = matchSwitcherAction(event);
if (action === null) return;
const next = resolveSwitcherTarget(
action,
current.providerIds,
current.selectedProviderId,
);
if (next === null) return;
event.preventDefault();
current.onSelect(next.providerId);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}
17 changes: 15 additions & 2 deletions apps/desktop-tauri/src/hooks/useTrayPanelController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useProviders } from "./useProviders";
import { useSettings } from "./useSettings";
import { useUpdateState } from "./useUpdateState";
import { useLocale } from "./useLocale";
import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys";
import { useSurfaceTarget } from "./useSurfaceMode";
import { useTrayPanelLayout } from "./useTrayPanelLayout";
import type { MenuFooterRow } from "../components/MenuSurface";
Expand Down Expand Up @@ -150,6 +151,10 @@ export function useTrayPanelController(state: BootstrapState) {
return hydrateProviderSlots(denseProviderSlots, providersById);
}, [denseProviderSlots, expectsDenseOverview, providersById, sorted]);

// What the switcher grid displays: the dense overview shows hydrated slots
// (with placeholders); everything else shows the sorted providers.
const gridProviders = expectsDenseOverview ? denseTrayProviders : sorted;

useEffect(() => {
setSelectedProviderId(initialProviderId);
}, [initialProviderId]);
Expand Down Expand Up @@ -345,6 +350,15 @@ export function useTrayPanelController(state: BootstrapState) {
},
[],
);
const gridProviderIds = useMemo(
() => gridProviders.map((provider) => provider.providerId),
[gridProviders],
);
useProviderSwitcherKeys({
providerIds: gridProviderIds,
selectedProviderId,
onSelect: handleGridClick,
});
const handleReorder = useCallback((orderedIds: string[]) => {
void reorderProviders(orderedIds).catch(() => {});
}, []);
Expand All @@ -369,8 +383,7 @@ export function useTrayPanelController(state: BootstrapState) {
trayScaleFillPercent,
handleTrayScaleChange,
sorted,
denseTrayProviders,
expectsDenseOverview,
gridProviders,
selectedProviderId,
gridExpanded,
setGridExpanded,
Expand Down
Loading