feat(app): cycle agent modes with Shift+Tab
This commit is contained in:
@@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
@@ -106,7 +113,10 @@ function AgentModeControlView({
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -146,6 +156,26 @@ function AgentModeControlView({
|
||||
[onSelectMode, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "message-input.mode-cycle") return false;
|
||||
if (disabled || !isActiveComposer) return false;
|
||||
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
|
||||
if (!nextModeId) return false;
|
||||
onSelectMode(nextModeId);
|
||||
return true;
|
||||
},
|
||||
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: ["message-input.mode-cycle"],
|
||||
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
|
||||
priority: 200,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const renderOption = useCallback(
|
||||
(args: {
|
||||
option: ComboboxOption;
|
||||
@@ -194,21 +224,31 @@ function AgentModeControlView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
|
||||
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode.id}
|
||||
@@ -373,4 +413,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentControlsMode } from "./mode";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
|
||||
|
||||
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
|
||||
|
||||
const MODES = [
|
||||
PLAN_MODE,
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "full-access", label: "Full Access" },
|
||||
] satisfies AgentMode[];
|
||||
|
||||
describe("resolveAgentControlsMode", () => {
|
||||
it("uses ready mode when no controlled agent controls are provided", () => {
|
||||
@@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => {
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextAgentModeId", () => {
|
||||
it("cycles from the selected mode to the next mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps from the last mode to the first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
|
||||
});
|
||||
|
||||
it("treats a stale selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
|
||||
"build",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there are fewer than two modes", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
|
||||
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function resolveNextAgentModeId({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
}: {
|
||||
modeOptions: readonly AgentMode[];
|
||||
selectedMode: string | null | undefined;
|
||||
}): string | null {
|
||||
if (modeOptions.length < 2) return null;
|
||||
|
||||
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
|
||||
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = (currentIndex + 1) % modeOptions.length;
|
||||
return modeOptions[nextIndex]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
|
||||
return agentControls ? "draft" : "ready";
|
||||
|
||||
Reference in New Issue
Block a user