mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Switch models from the Command Center (#2147)
* Switch models from the Command Center Add a model switcher to the Command-K Command Center. Typing surfaces a flat, filterable list of "Model › Provider › Name" breadcrumb rows with provider icons: - Running agent: its own provider's models (a live agent can't change provider); selecting calls setAgentModel. - New draft tab: every available provider's models in one flat list; selecting sets provider + model on the draft via a focused-draft controller published to a global store (the draft form state is local to the composer subtree and otherwise unreachable from the global Command Center). Models only appear once the user starts typing, so the default palette view is unchanged. Reuses useProvidersSnapshot and the existing setAgentModel RPC — no protocol changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update packages/app/src/components/command-center.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update packages/app/src/hooks/use-command-center.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * refactor(app): make Command Center extensible Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection. * fix(app): save model preference after agent switch Persist the shared model choice only after the daemon confirms the live agent switched successfully. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
committed by
GitHub
parent
7917532716
commit
45bbb973a3
@@ -21,7 +21,8 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -553,6 +554,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
@@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
return content;
|
||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
|
||||
792
packages/app/src/command-center/command-center.tsx
Normal file
792
packages/app/src/command-center/command-center.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type ListRenderItemInfo,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetFlatList,
|
||||
BottomSheetTextInput,
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
|
||||
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
|
||||
import {
|
||||
buildContributionSections,
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterListRow,
|
||||
type CommandCenterResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedSettings = withUnistyles(Settings, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function PlusIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPlus size={size} strokeWidth={2.4} />;
|
||||
}
|
||||
|
||||
function SettingsIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedSettings size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[][] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const platform = {
|
||||
isMac: getShortcutOs() === "mac",
|
||||
isDesktop: getIsElectronRuntime(),
|
||||
};
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : undefined;
|
||||
}
|
||||
|
||||
export function CommandCenterRootActions() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
const actions = useMemo<CommandCenterContribution[]>(
|
||||
() => [
|
||||
{
|
||||
id: "new-agent",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 0,
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openAddProject();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.addProject"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: PlusIcon,
|
||||
shortcutKeys: resolveActionShortcutKeys("new-agent", overrides),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 1,
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(homeRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.home"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: HomeIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 2,
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(settingsRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.actions.settings"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
},
|
||||
],
|
||||
[homeRoute, openAddProject, overrides, settingsRoute, t],
|
||||
);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
}
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||
const leftRunning = left.status === "running" ? 1 : 0;
|
||||
const rightRunning = right.status === "running" ? 1 : 0;
|
||||
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function matchesQuery(searchText: string, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return !normalized || searchText.includes(normalized);
|
||||
}
|
||||
|
||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||
const { t } = useTranslation();
|
||||
const { agents } = useAggregatedAgents();
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showAgentHost = useHosts().length > 1;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return [];
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
const title = workspace.title ?? workspace.name;
|
||||
const subtitle = workspace.currentBranch
|
||||
? `${host.serverName} · ${workspace.currentBranch}`
|
||||
: host.serverName;
|
||||
const searchText = `${title} ${subtitle}`.toLowerCase();
|
||||
allWorkspaces.push({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title,
|
||||
subtitle,
|
||||
searchText,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
allWorkspaces.sort((left, right) => {
|
||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
||||
});
|
||||
const workspaceTitleByKey = new Map(
|
||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||
);
|
||||
const workspaces = allWorkspaces.filter((workspace) =>
|
||||
matchesQuery(workspace.searchText, query),
|
||||
);
|
||||
const agentResults = agents
|
||||
.map<CommandCenterAgentResult>((agent) => {
|
||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
||||
const subtitle = [
|
||||
showAgentHost ? agent.serverLabel : null,
|
||||
location,
|
||||
formatTimeAgo(agent.lastActivityAt),
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(" · ");
|
||||
return {
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title,
|
||||
subtitle,
|
||||
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((agent) => matchesQuery(agent.searchText, query))
|
||||
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
return [
|
||||
{
|
||||
id: "workspaces",
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: workspaces,
|
||||
},
|
||||
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
|
||||
];
|
||||
}, [agents, open, projects, query, showAgentHost, t]);
|
||||
}
|
||||
|
||||
interface CommandCenterState {
|
||||
open: boolean;
|
||||
query: string;
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
rows: readonly CommandCenterListRow[];
|
||||
results: readonly CommandCenterResult[];
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<TextInput | null>;
|
||||
close(): void;
|
||||
select(result: CommandCenterResult): void;
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(): CommandCenterState {
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const contributionSections = useMemo(
|
||||
() => buildContributionSections(snapshot.contributions, query),
|
||||
[query, snapshot.contributions],
|
||||
);
|
||||
const projection = useMemo(
|
||||
() => projectCommandCenterRows([...contributionSections, ...builtInSections]),
|
||||
[builtInSections, contributionSections],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
(result: CommandCenterResult) => {
|
||||
setOpen(false);
|
||||
void result.run();
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
const key = useCallback(
|
||||
(pressed: string): boolean => {
|
||||
if (!open) return false;
|
||||
const results = projection.selectableResults;
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
const selected = results.find((result) => result.id === resolvedActiveId);
|
||||
if (!selected) return false;
|
||||
select(selected);
|
||||
return true;
|
||||
}
|
||||
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
|
||||
if (results.length === 0) return false;
|
||||
const direction = pressed === "ArrowDown" ? "next" : "previous";
|
||||
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
|
||||
return true;
|
||||
},
|
||||
[close, open, projection.selectableResults, resolvedActiveId, select],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = previousOpenRef.current;
|
||||
previousOpenRef.current = open;
|
||||
if (open) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setQuery("");
|
||||
setActiveId(null);
|
||||
if (!wasOpen) return;
|
||||
const element = takeCommandCenterFocusRestoreElement();
|
||||
if (!element) return;
|
||||
const cancel = focusWithRetries({
|
||||
focus: () => element.focus(),
|
||||
isFocused: () => typeof document !== "undefined" && document.activeElement === element,
|
||||
onTimeout: () =>
|
||||
keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }),
|
||||
});
|
||||
return cancel;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isWeb) return;
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (key(event.key)) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", listener, true);
|
||||
return () => window.removeEventListener("keydown", listener, true);
|
||||
}, [key, open]);
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
setQuery,
|
||||
activeId: resolvedActiveId,
|
||||
rows: projection.rows,
|
||||
results: projection.selectableResults,
|
||||
rowIndexByResultId: projection.rowIndexByResultId,
|
||||
offsets: projection.offsets,
|
||||
inputRef,
|
||||
close,
|
||||
select,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
interface ResultRowProps {
|
||||
result: CommandCenterResult;
|
||||
active: boolean;
|
||||
onSelect(result: CommandCenterResult): void;
|
||||
}
|
||||
|
||||
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(result.kind === "agent" ||
|
||||
result.kind === "workspace" ||
|
||||
(result.kind === "contribution" &&
|
||||
result.contribution.presentation.kind === "action" &&
|
||||
Boolean(result.contribution.presentation.subtitle))) &&
|
||||
styles.tallRow,
|
||||
(Boolean(hovered) || pressed || active) && styles.activeRow,
|
||||
],
|
||||
[active, result],
|
||||
);
|
||||
return (
|
||||
<Pressable style={style} onPress={press}>
|
||||
<ResultContent result={result} />
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
if (result.kind === "agent") {
|
||||
const agent = result.agent;
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
showInactive
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (result.kind === "workspace") {
|
||||
const key = result.id.slice("workspace:".length);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<ThemedFolder size={16} strokeWidth={2.2} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{result.title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const presentation = result.contribution.presentation;
|
||||
const Icon = presentation.icon;
|
||||
if (presentation.kind === "action") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
{Icon ? (
|
||||
<View style={styles.iconSlot}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{presentation.title}
|
||||
</Text>
|
||||
{presentation.subtitle ? (
|
||||
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{presentation.shortcutKeys ? (
|
||||
<Shortcut chord={presentation.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.rowContent} testID={presentation.testId}>
|
||||
<View style={styles.rowMain}>
|
||||
{Icon ? (
|
||||
<View style={styles.iconSlot}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.breadcrumb}>
|
||||
{presentation.path.map((part, index) => (
|
||||
<View
|
||||
key={presentation.path.slice(0, index + 1).join("\u0000")}
|
||||
style={styles.breadcrumbPart}
|
||||
>
|
||||
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
|
||||
<Text
|
||||
style={
|
||||
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{presentation.selected ? (
|
||||
<View style={styles.iconSlot}>
|
||||
<ThemedCheck size={16} strokeWidth={2.2} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
|
||||
let sizeStyle = styles.dividerSection;
|
||||
if (row.title && row.divider) sizeStyle = styles.dividedSection;
|
||||
if (row.title && !row.divider) sizeStyle = styles.titledSection;
|
||||
return (
|
||||
<View style={sizeStyle}>
|
||||
{row.divider ? <View style={styles.sectionDivider} /> : null}
|
||||
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const state = useCommandCenterState();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: state.close,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open || !state.activeId) return;
|
||||
const index = state.rowIndexByResultId.get(state.activeId);
|
||||
if (index === undefined) return;
|
||||
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
|
||||
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
|
||||
useEffect(() => {
|
||||
if (!showBottomSheet || !state.open) return;
|
||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [showBottomSheet, state.open]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: ListRenderItemInfo<CommandCenterListRow>) =>
|
||||
item.kind === "section" ? (
|
||||
<SectionRow row={item} />
|
||||
) : (
|
||||
<ResultRow
|
||||
result={item.result}
|
||||
active={item.result.id === state.activeId}
|
||||
onSelect={state.select}
|
||||
/>
|
||||
),
|
||||
[state.activeId, state.select],
|
||||
);
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
|
||||
index,
|
||||
length: state.rows[index].height,
|
||||
offset: state.offsets[index],
|
||||
}),
|
||||
[state.offsets, state.rows],
|
||||
);
|
||||
const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
|
||||
const empty = useMemo(
|
||||
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
|
||||
[t],
|
||||
);
|
||||
const commonListProps = {
|
||||
data: state.rows,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
getItemLayout,
|
||||
ListEmptyComponent: empty,
|
||||
keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
|
||||
showsVerticalScrollIndicator: false,
|
||||
initialNumToRender: 12,
|
||||
maxToRenderPerBatch: 10,
|
||||
windowSize: 5,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const backdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={backdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
panel: {
|
||||
width: 640,
|
||||
maxWidth: "92%",
|
||||
maxHeight: "80%",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
input: {
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[1],
|
||||
color: theme.colors.foreground,
|
||||
outlineWidth: 0,
|
||||
},
|
||||
results: { flexGrow: 0 },
|
||||
sectionLabel: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionDivider: {
|
||||
height: 1,
|
||||
marginTop: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
|
||||
tallRow: { height: 56 },
|
||||
activeRow: { backgroundColor: theme.colors.surface1 },
|
||||
rowContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
textContent: { flex: 1, minWidth: 0 },
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
flexShrink: 1,
|
||||
},
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 },
|
||||
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||
rowShortcut: { flexShrink: 0 },
|
||||
breadcrumb: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
breadcrumbPart: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 1,
|
||||
},
|
||||
breadcrumbGroup: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
flexShrink: 0,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
sheetBackground: { backgroundColor: theme.colors.surface0 },
|
||||
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
titledSection: { height: 32, justifyContent: "flex-end" },
|
||||
dividedSection: { height: 49, justifyContent: "flex-end" },
|
||||
dividerSection: { height: 17, justifyContent: "flex-end" },
|
||||
}));
|
||||
53
packages/app/src/command-center/contributions.ts
Normal file
53
packages/app/src/command-center/contributions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
export interface CommandCenterIconProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type CommandCenterIcon = ComponentType<CommandCenterIconProps>;
|
||||
|
||||
interface CommandCenterContributionBase {
|
||||
id: string;
|
||||
group: string;
|
||||
groupRank: number;
|
||||
rank: number;
|
||||
keywords: readonly string[];
|
||||
visibility: "always" | "query";
|
||||
run(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type CommandCenterContribution =
|
||||
| (CommandCenterContributionBase & {
|
||||
presentation: {
|
||||
kind: "action";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sectionTitle?: string;
|
||||
icon?: CommandCenterIcon;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
};
|
||||
})
|
||||
| (CommandCenterContributionBase & {
|
||||
presentation: {
|
||||
kind: "choice";
|
||||
path: readonly [string, ...string[]];
|
||||
icon?: CommandCenterIcon;
|
||||
selected: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
});
|
||||
|
||||
export interface CommandCenterContributionSnapshot {
|
||||
contributions: readonly CommandCenterContribution[];
|
||||
}
|
||||
|
||||
export interface CommandCenterRegistrationOwner {
|
||||
sourceId: string;
|
||||
token: symbol;
|
||||
}
|
||||
|
||||
export interface CommandCenterRegistration {
|
||||
owner: CommandCenterRegistrationOwner;
|
||||
contributions: readonly CommandCenterContribution[];
|
||||
}
|
||||
104
packages/app/src/command-center/model-contributions.test.tsx
Normal file
104
packages/app/src/command-center/model-contributions.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { buildModelChoiceContributions } from "./model-contributions";
|
||||
|
||||
function TestIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function provider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
state: "models" | "error";
|
||||
}): ProviderSelectorProvider {
|
||||
if (input.state === "error") {
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: { kind: "error", message: "Unavailable" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: `${input.id}:model`,
|
||||
provider: input.id,
|
||||
providerLabel: input.label,
|
||||
modelId: "model",
|
||||
modelLabel: `${input.label} model`,
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Command Center model choices", () => {
|
||||
it("publishes selectable providers and executes the draft owner's command", () => {
|
||||
const selections: string[] = [];
|
||||
const choices = buildModelChoiceContributions({
|
||||
serverId: "host",
|
||||
providers: [
|
||||
provider({ id: "claude", label: "Claude", state: "models" }),
|
||||
provider({ id: "codex", label: "Codex", state: "models" }),
|
||||
],
|
||||
selectedProvider: "codex",
|
||||
selectedModelId: "model",
|
||||
groupLabel: "Model",
|
||||
searchKeywords: "model switch",
|
||||
getIcon: () => TestIcon,
|
||||
select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`),
|
||||
});
|
||||
|
||||
expect(
|
||||
choices.map((choice) => ({
|
||||
id: choice.id,
|
||||
selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "host:claude:model", selected: false },
|
||||
{ id: "host:codex:model", selected: true },
|
||||
]);
|
||||
choices[0].run();
|
||||
choices[1].run();
|
||||
expect(selections).toEqual(["claude:model"]);
|
||||
});
|
||||
|
||||
it("does not publish unavailable providers or no-op default rows", () => {
|
||||
const choices = buildModelChoiceContributions({
|
||||
serverId: "host",
|
||||
providers: [
|
||||
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
|
||||
{
|
||||
id: "empty",
|
||||
label: "Empty",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "empty:",
|
||||
provider: "empty",
|
||||
providerLabel: "Empty",
|
||||
modelId: "",
|
||||
modelLabel: "Default",
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedProvider: null,
|
||||
selectedModelId: null,
|
||||
groupLabel: "Model",
|
||||
searchKeywords: "model switch",
|
||||
getIcon: () => TestIcon,
|
||||
select: () => undefined,
|
||||
});
|
||||
|
||||
expect(choices).toEqual([]);
|
||||
});
|
||||
});
|
||||
51
packages/app/src/command-center/model-contributions.ts
Normal file
51
packages/app/src/command-center/model-contributions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
interface ModelChoiceInput {
|
||||
serverId: string;
|
||||
providers: readonly ProviderSelectorProvider[];
|
||||
selectedProvider: string | null;
|
||||
selectedModelId: string | null;
|
||||
groupLabel: string;
|
||||
searchKeywords: string;
|
||||
getIcon(provider: AgentProvider): CommandCenterIcon;
|
||||
select(provider: AgentProvider, modelId: string): void;
|
||||
}
|
||||
|
||||
export function buildModelChoiceContributions(
|
||||
input: ModelChoiceInput,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
let rank = 0;
|
||||
for (const provider of input.providers) {
|
||||
if (provider.modelSelection.kind !== "models") continue;
|
||||
const agentProvider = provider.id;
|
||||
const icon = input.getIcon(agentProvider);
|
||||
for (const model of provider.modelSelection.rows) {
|
||||
if (!model.modelId) continue;
|
||||
const modelId = model.modelId;
|
||||
const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId;
|
||||
contributions.push({
|
||||
id: `${input.serverId}:${provider.id}:${modelId}`,
|
||||
group: "models",
|
||||
groupRank: 1,
|
||||
rank,
|
||||
keywords: [modelId, input.searchKeywords],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
if (!selected) input.select(agentProvider, modelId);
|
||||
},
|
||||
presentation: {
|
||||
kind: "choice",
|
||||
path: [input.groupLabel, provider.label, model.modelLabel],
|
||||
icon,
|
||||
selected,
|
||||
testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`,
|
||||
},
|
||||
});
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
20
packages/app/src/command-center/provider-icon.tsx
Normal file
20
packages/app/src/command-center/provider-icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
|
||||
|
||||
const commandCenterProviderIcons = new Map<AgentProvider, CommandCenterIcon>();
|
||||
|
||||
export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon {
|
||||
const cached = commandCenterProviderIcons.get(provider);
|
||||
if (cached) return cached;
|
||||
|
||||
const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
function CommandCenterProviderIcon({ size }: CommandCenterIconProps) {
|
||||
return <ProviderIcon size={size} />;
|
||||
}
|
||||
commandCenterProviderIcons.set(provider, CommandCenterProviderIcon);
|
||||
return CommandCenterProviderIcon;
|
||||
}
|
||||
56
packages/app/src/command-center/provider.tsx
Normal file
56
packages/app/src/command-center/provider.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import { createCommandCenterRegistry, type CommandCenterRegistry } from "./registry";
|
||||
|
||||
const CommandCenterRegistryContext = createContext<CommandCenterRegistry | null>(null);
|
||||
|
||||
export function CommandCenterProvider({ children }: { children: ReactNode }) {
|
||||
const registryRef = useRef<CommandCenterRegistry | null>(null);
|
||||
if (!registryRef.current) registryRef.current = createCommandCenterRegistry();
|
||||
|
||||
return (
|
||||
<CommandCenterRegistryContext.Provider value={registryRef.current}>
|
||||
{children}
|
||||
</CommandCenterRegistryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useCommandCenterRegistry(): CommandCenterRegistry {
|
||||
const registry = useContext(CommandCenterRegistryContext);
|
||||
if (!registry) throw new Error("CommandCenterProvider is required");
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function useCommandCenterContributions() {
|
||||
const registry = useCommandCenterRegistry();
|
||||
return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
|
||||
}
|
||||
|
||||
export function useCommandCenterActions(input: {
|
||||
sourceId: string;
|
||||
enabled: boolean;
|
||||
actions: readonly CommandCenterContribution[];
|
||||
}): void {
|
||||
const registry = useCommandCenterRegistry();
|
||||
const ownerRef = useRef({ sourceId: input.sourceId, token: Symbol(input.sourceId) });
|
||||
if (ownerRef.current.sourceId !== input.sourceId) {
|
||||
ownerRef.current = { sourceId: input.sourceId, token: Symbol(input.sourceId) };
|
||||
}
|
||||
const owner = ownerRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.enabled) {
|
||||
registry.remove(owner);
|
||||
return;
|
||||
}
|
||||
registry.replace({ owner, contributions: input.actions });
|
||||
return () => registry.remove(owner);
|
||||
}, [input.actions, input.enabled, owner, registry]);
|
||||
}
|
||||
73
packages/app/src/command-center/registry.test.ts
Normal file
73
packages/app/src/command-center/registry.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterContribution, CommandCenterRegistrationOwner } from "./contributions";
|
||||
import { createCommandCenterRegistry } from "./registry";
|
||||
|
||||
function action(id: string, rank: number): CommandCenterContribution {
|
||||
return {
|
||||
id,
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank,
|
||||
keywords: [],
|
||||
visibility: "always",
|
||||
run: () => undefined,
|
||||
presentation: { kind: "action", title: id },
|
||||
};
|
||||
}
|
||||
|
||||
function owner(sourceId: string): CommandCenterRegistrationOwner {
|
||||
return { sourceId, token: Symbol(sourceId) };
|
||||
}
|
||||
|
||||
describe("Command Center registry", () => {
|
||||
it("atomically replaces a source and preserves a no-op snapshot", () => {
|
||||
const registry = createCommandCenterRegistry();
|
||||
const source = owner("root");
|
||||
const first = [action("first", 0)];
|
||||
let notifications = 0;
|
||||
registry.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
registry.replace({ owner: source, contributions: first });
|
||||
const snapshot = registry.getSnapshot();
|
||||
registry.replace({ owner: source, contributions: first });
|
||||
expect(registry.getSnapshot()).toBe(snapshot);
|
||||
expect(notifications).toBe(1);
|
||||
|
||||
registry.replace({ owner: source, contributions: [action("second", 0)] });
|
||||
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["root:second"]);
|
||||
expect(notifications).toBe(2);
|
||||
});
|
||||
|
||||
it("does not let stale cleanup remove a replacement owner", () => {
|
||||
const registry = createCommandCenterRegistry();
|
||||
const stale = owner("draft:tab");
|
||||
const current = owner("draft:tab");
|
||||
registry.replace({ owner: stale, contributions: [action("old", 0)] });
|
||||
registry.replace({ owner: current, contributions: [action("new", 0)] });
|
||||
|
||||
registry.remove(stale);
|
||||
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["draft:tab:new"]);
|
||||
registry.remove(current);
|
||||
expect(registry.getSnapshot().contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders independently of registration order and rejects duplicate active ids", () => {
|
||||
const registry = createCommandCenterRegistry();
|
||||
registry.replace({ owner: owner("later"), contributions: [action("z", 2)] });
|
||||
registry.replace({ owner: owner("earlier"), contributions: [action("a", 1)] });
|
||||
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual([
|
||||
"earlier:a",
|
||||
"later:z",
|
||||
]);
|
||||
|
||||
const duplicateOwner = owner("duplicate");
|
||||
expect(() =>
|
||||
registry.replace({
|
||||
owner: duplicateOwner,
|
||||
contributions: [action("same", 0), action("same", 1)],
|
||||
}),
|
||||
).toThrow("Duplicate Command Center contribution id: duplicate:same");
|
||||
});
|
||||
});
|
||||
100
packages/app/src/command-center/registry.ts
Normal file
100
packages/app/src/command-center/registry.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
CommandCenterContribution,
|
||||
CommandCenterContributionSnapshot,
|
||||
CommandCenterRegistration,
|
||||
CommandCenterRegistrationOwner,
|
||||
} from "./contributions";
|
||||
|
||||
export interface CommandCenterRegistry {
|
||||
getSnapshot(): CommandCenterContributionSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
replace(registration: CommandCenterRegistration): void;
|
||||
remove(owner: CommandCenterRegistrationOwner): void;
|
||||
}
|
||||
|
||||
interface ActiveRegistration {
|
||||
owner: CommandCenterRegistrationOwner;
|
||||
contributions: readonly CommandCenterContribution[];
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: CommandCenterContributionSnapshot = { contributions: [] };
|
||||
|
||||
function contributionId(sourceId: string, id: string): string {
|
||||
return `${sourceId}:${id}`;
|
||||
}
|
||||
|
||||
function compareContributions(
|
||||
left: CommandCenterContribution,
|
||||
right: CommandCenterContribution,
|
||||
): number {
|
||||
if (left.groupRank !== right.groupRank) return left.groupRank - right.groupRank;
|
||||
const groupDelta = left.group.localeCompare(right.group);
|
||||
if (groupDelta !== 0) return groupDelta;
|
||||
if (left.rank !== right.rank) return left.rank - right.rank;
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function sameContributions(
|
||||
left: readonly CommandCenterContribution[],
|
||||
right: readonly CommandCenterContribution[],
|
||||
): boolean {
|
||||
return left.length === right.length && left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
export function createCommandCenterRegistry(): CommandCenterRegistry {
|
||||
const registrations = new Map<string, ActiveRegistration>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot = EMPTY_SNAPSHOT;
|
||||
|
||||
function publish(): void {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const registration of registrations.values()) {
|
||||
for (const contribution of registration.contributions) {
|
||||
const id = contributionId(registration.owner.sourceId, contribution.id);
|
||||
if (ids.has(id)) {
|
||||
throw new Error(`Duplicate Command Center contribution id: ${id}`);
|
||||
}
|
||||
ids.add(id);
|
||||
contributions.push({ ...contribution, id });
|
||||
}
|
||||
}
|
||||
contributions.sort(compareContributions);
|
||||
|
||||
if (sameContributions(snapshot.contributions, contributions)) return;
|
||||
snapshot = contributions.length === 0 ? EMPTY_SNAPSHOT : { contributions };
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
replace(registration) {
|
||||
const current = registrations.get(registration.owner.sourceId);
|
||||
if (
|
||||
current?.owner.token === registration.owner.token &&
|
||||
sameContributions(current.contributions, registration.contributions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const contribution of registration.contributions) {
|
||||
const id = contributionId(registration.owner.sourceId, contribution.id);
|
||||
if (ids.has(id)) throw new Error(`Duplicate Command Center contribution id: ${id}`);
|
||||
ids.add(id);
|
||||
}
|
||||
registrations.set(registration.owner.sourceId, registration);
|
||||
publish();
|
||||
},
|
||||
remove(owner) {
|
||||
const current = registrations.get(owner.sourceId);
|
||||
if (current?.owner.token !== owner.token) return;
|
||||
registrations.delete(owner.sourceId);
|
||||
publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
105
packages/app/src/command-center/results.test.ts
Normal file
105
packages/app/src/command-center/results.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
buildContributionSections,
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
function contribution(input: {
|
||||
id: string;
|
||||
group: string;
|
||||
groupRank: number;
|
||||
visibility?: "always" | "query";
|
||||
}): CommandCenterContribution {
|
||||
return {
|
||||
...input,
|
||||
rank: 0,
|
||||
keywords: [input.id],
|
||||
visibility: input.visibility ?? "always",
|
||||
run: () => undefined,
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: input.id,
|
||||
sectionTitle: input.group,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(id: string): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id,
|
||||
title: id,
|
||||
subtitle: "host",
|
||||
searchText: id,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const section of sections) {
|
||||
for (const result of section.results) ids.push(result.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("Command Center result projection", () => {
|
||||
it("query-gates model choices and creates one flat row index", () => {
|
||||
const contributions = [
|
||||
contribution({ id: "settings", group: "actions", groupRank: 0 }),
|
||||
contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
|
||||
];
|
||||
const emptySections = buildContributionSections(contributions, "");
|
||||
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
|
||||
|
||||
const sections = buildContributionSections(contributions, "o");
|
||||
const projection = projectCommandCenterRows([
|
||||
...sections,
|
||||
{ id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
|
||||
]);
|
||||
expect(projection.rows.map((row) => row.key)).toEqual([
|
||||
"section:models",
|
||||
"opus",
|
||||
"section:workspaces",
|
||||
"workspace:1",
|
||||
]);
|
||||
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
|
||||
expect(projection.offsets).toEqual([0, 32, 68, 117]);
|
||||
});
|
||||
|
||||
it("preserves active selection by id and falls back to the first result", () => {
|
||||
const first = workspace("workspace:first");
|
||||
const second = workspace("workspace:second");
|
||||
expect(preserveActiveResultId(second.id, [first, second])).toBe(second.id);
|
||||
expect(preserveActiveResultId("missing", [first, second])).toBe(first.id);
|
||||
expect(preserveActiveResultId(first.id, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps keyboard selection in both directions", () => {
|
||||
const first = workspace("workspace:first");
|
||||
const second = workspace("workspace:second");
|
||||
expect(moveActiveResultId(second.id, [first, second], "next")).toBe(first.id);
|
||||
expect(moveActiveResultId(first.id, [first, second], "previous")).toBe(second.id);
|
||||
});
|
||||
|
||||
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
||||
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
||||
const projection = projectCommandCenterRows([
|
||||
{ id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
|
||||
]);
|
||||
|
||||
let activeId: string | null = workspaces[0].id;
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
activeId = moveActiveResultId(activeId, projection.selectableResults, "next");
|
||||
}
|
||||
|
||||
const targetId = workspaces[150].id;
|
||||
expect(activeId).toBe(targetId);
|
||||
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
|
||||
expect(projection.offsets[151]).toBe(32 + 150 * 56);
|
||||
});
|
||||
});
|
||||
181
packages/app/src/command-center/results.ts
Normal file
181
packages/app/src/command-center/results.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
|
||||
export interface CommandCenterWorkspaceResult {
|
||||
kind: "workspace";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterAgentResult {
|
||||
kind: "agent";
|
||||
id: string;
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterContributionResult {
|
||||
kind: "contribution";
|
||||
id: string;
|
||||
contribution: CommandCenterContribution;
|
||||
searchText: string;
|
||||
run(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type CommandCenterResult =
|
||||
| CommandCenterWorkspaceResult
|
||||
| CommandCenterAgentResult
|
||||
| CommandCenterContributionResult;
|
||||
|
||||
export interface CommandCenterResultSection {
|
||||
id: string;
|
||||
rank: number;
|
||||
title?: string;
|
||||
results: readonly CommandCenterResult[];
|
||||
}
|
||||
|
||||
interface MutableCommandCenterResultSection {
|
||||
id: string;
|
||||
rank: number;
|
||||
title?: string;
|
||||
results: CommandCenterResult[];
|
||||
}
|
||||
|
||||
export type CommandCenterListRow =
|
||||
| { kind: "section"; key: string; title?: string; divider: boolean; height: number }
|
||||
| { kind: "result"; key: string; result: CommandCenterResult; height: number };
|
||||
|
||||
export interface CommandCenterListProjection {
|
||||
rows: readonly CommandCenterListRow[];
|
||||
selectableResults: readonly CommandCenterResult[];
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
}
|
||||
|
||||
function matchesQuery(searchText: string, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return !normalized || searchText.includes(normalized);
|
||||
}
|
||||
|
||||
function contributionSearchText(contribution: CommandCenterContribution): string {
|
||||
const presentationText =
|
||||
contribution.presentation.kind === "action"
|
||||
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
|
||||
: contribution.presentation.path;
|
||||
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function resultHeight(result: CommandCenterResult): number {
|
||||
if (result.kind === "workspace" || result.kind === "agent") return 56;
|
||||
if (result.contribution.presentation.kind === "action") {
|
||||
return result.contribution.presentation.subtitle ? 56 : 36;
|
||||
}
|
||||
return 36;
|
||||
}
|
||||
|
||||
export function buildContributionSections(
|
||||
contributions: readonly CommandCenterContribution[],
|
||||
query: string,
|
||||
): CommandCenterResultSection[] {
|
||||
const groups = new Map<string, MutableCommandCenterResultSection>();
|
||||
const hasQuery = Boolean(query.trim());
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (contribution.visibility === "query" && !hasQuery) continue;
|
||||
const searchText = contributionSearchText(contribution);
|
||||
if (!matchesQuery(searchText, query)) continue;
|
||||
const existing = groups.get(contribution.group);
|
||||
const title =
|
||||
contribution.presentation.kind === "action"
|
||||
? contribution.presentation.sectionTitle
|
||||
: undefined;
|
||||
const section = existing ?? {
|
||||
id: contribution.group,
|
||||
rank: contribution.groupRank,
|
||||
title,
|
||||
results: [],
|
||||
};
|
||||
section.results.push({
|
||||
kind: "contribution",
|
||||
id: contribution.id,
|
||||
contribution,
|
||||
searchText,
|
||||
run: contribution.run,
|
||||
});
|
||||
groups.set(contribution.group, section);
|
||||
}
|
||||
|
||||
return [...groups.values()].sort(
|
||||
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function projectCommandCenterRows(
|
||||
sections: readonly CommandCenterResultSection[],
|
||||
): CommandCenterListProjection {
|
||||
const populated = sections
|
||||
.filter((section) => section.results.length > 0)
|
||||
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
|
||||
const rows: CommandCenterListRow[] = [];
|
||||
const selectableResults: CommandCenterResult[] = [];
|
||||
const rowIndexByResultId = new Map<string, number>();
|
||||
const offsets: number[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const [sectionIndex, section] of populated.entries()) {
|
||||
const divider = sectionIndex > 0;
|
||||
let sectionHeight = 0;
|
||||
if (section.title && divider) sectionHeight = 49;
|
||||
if (section.title && !divider) sectionHeight = 32;
|
||||
if (!section.title && divider) sectionHeight = 17;
|
||||
if (sectionHeight > 0) {
|
||||
offsets.push(offset);
|
||||
rows.push({
|
||||
kind: "section",
|
||||
key: `section:${section.id}`,
|
||||
title: section.title,
|
||||
divider,
|
||||
height: sectionHeight,
|
||||
});
|
||||
offset += sectionHeight;
|
||||
}
|
||||
for (const result of section.results) {
|
||||
const height = resultHeight(result);
|
||||
offsets.push(offset);
|
||||
rowIndexByResultId.set(result.id, rows.length);
|
||||
rows.push({ kind: "result", key: result.id, result, height });
|
||||
selectableResults.push(result);
|
||||
offset += height;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, selectableResults, rowIndexByResultId, offsets };
|
||||
}
|
||||
|
||||
export function preserveActiveResultId(
|
||||
activeId: string | null,
|
||||
results: readonly CommandCenterResult[],
|
||||
): string | null {
|
||||
if (activeId && results.some((result) => result.id === activeId)) return activeId;
|
||||
return results[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function moveActiveResultId(
|
||||
activeId: string | null,
|
||||
results: readonly CommandCenterResult[],
|
||||
direction: "next" | "previous",
|
||||
): string | null {
|
||||
if (results.length === 0) return null;
|
||||
const current = results.findIndex((result) => result.id === activeId);
|
||||
const delta = direction === "next" ? 1 : -1;
|
||||
let start = current;
|
||||
if (current < 0 && direction === "previous") start = 0;
|
||||
const next = (start + delta + results.length) % results.length;
|
||||
return results[next].id;
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Folder, Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
useCommandCenter,
|
||||
type CommandCenterActionItem,
|
||||
type CommandCenterAgentItem,
|
||||
type CommandCenterItem,
|
||||
type CommandCenterWorkspaceItem,
|
||||
} from "@/hooks/use-command-center";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedFolder = withUnistyles(Folder, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface CommandCenterRowProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
registerRow: (el: View | null) => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
}
|
||||
|
||||
const CommandCenterRow = memo(function CommandCenterRow({
|
||||
active,
|
||||
children,
|
||||
onPress,
|
||||
registerRow,
|
||||
onLayout,
|
||||
}: CommandCenterRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
],
|
||||
[active, theme.colors.surface1],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
interface CommandCenterRowContainerProps {
|
||||
item: CommandCenterItem;
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onSelect: (item: CommandCenterItem) => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CommandCenterRowContainer({
|
||||
item,
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onSelect,
|
||||
onLayout,
|
||||
children,
|
||||
}: CommandCenterRowContainerProps) {
|
||||
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
|
||||
const registerRow = useCallback(
|
||||
(el: View | null) => {
|
||||
if (el) rowRefs.current.set(rowIndex, el);
|
||||
else rowRefs.current.delete(rowIndex);
|
||||
},
|
||||
[rowRefs, rowIndex],
|
||||
);
|
||||
return (
|
||||
<CommandCenterRow
|
||||
active={active}
|
||||
registerRow={registerRow}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandCenterActionRowProps {
|
||||
item: CommandCenterActionItem;
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: CommandCenterItem) => void;
|
||||
}
|
||||
|
||||
function CommandCenterActionRow({
|
||||
item,
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
}: CommandCenterActionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
let actionIcon: React.ReactNode = null;
|
||||
if (item.icon === "plus") {
|
||||
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
|
||||
} else if (item.icon === "settings") {
|
||||
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
|
||||
} else if (item.icon === "home") {
|
||||
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
return (
|
||||
<CommandCenterRowContainer
|
||||
item={item}
|
||||
rowIndex={rowIndex}
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={onSelect}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
|
||||
<View style={styles.textContent}>
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{item.shortcutKeys ? (
|
||||
<Shortcut chord={item.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</CommandCenterRowContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
item: CommandCenterAgentItem;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const agent = item.agent;
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const subtitleStyle = useMemo(
|
||||
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
showInactive
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentItemsSectionProps {
|
||||
agentItems: CommandCenterAgentItem[];
|
||||
startIndex: number;
|
||||
activeIndex: number;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onRowLayout: (
|
||||
rowIndex: number,
|
||||
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: CommandCenterItem) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
agentItems,
|
||||
startIndex,
|
||||
activeIndex,
|
||||
rowRefs,
|
||||
onRowLayout,
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
|
||||
{agentItems.map((item, index) => {
|
||||
const rowIndex = startIndex + index;
|
||||
const agent = item.agent;
|
||||
return (
|
||||
<CommandCenterRowContainer
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
item={item}
|
||||
rowIndex={rowIndex}
|
||||
active={rowIndex === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent item={item} />
|
||||
</CommandCenterRowContainer>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceItemsSectionProps {
|
||||
workspaceItems: CommandCenterWorkspaceItem[];
|
||||
startIndex: number;
|
||||
activeIndex: number;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onRowLayout: (
|
||||
rowIndex: number,
|
||||
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: CommandCenterItem) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
}
|
||||
|
||||
function WorkspaceItemsSection({
|
||||
workspaceItems,
|
||||
startIndex,
|
||||
activeIndex,
|
||||
rowRefs,
|
||||
onRowLayout,
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
}: WorkspaceItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.workspaces")}</Text>
|
||||
{workspaceItems.map((item, index) => {
|
||||
const rowIndex = startIndex + index;
|
||||
return (
|
||||
<CommandCenterRowContainer
|
||||
key={`${item.serverId}:${item.workspaceId}`}
|
||||
item={item}
|
||||
rowIndex={rowIndex}
|
||||
active={rowIndex === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={onSelect}
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
>
|
||||
<View
|
||||
style={styles.rowContent}
|
||||
testID={`command-center-workspace-${item.serverId}:${item.workspaceId}`}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<ThemedFolder size={16} strokeWidth={2.2} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CommandCenterRowContainer>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
} = useCommandCenter();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
const nativeScrollY = useRef(0);
|
||||
const nativeViewHeight = useRef(0);
|
||||
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
|
||||
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bottomSheetInputRef = useRef<any>(null);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
// Focus the bottom sheet input when the sheet opens on mobile
|
||||
useEffect(() => {
|
||||
if (showBottomSheet && open) {
|
||||
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [showBottomSheet, open]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll active row into view
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (isWeb) {
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") return;
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Native: use onLayout-measured positions
|
||||
const layout = rowLayouts.current.get(activeIndex);
|
||||
if (!layout || !resultsRef.current) return;
|
||||
|
||||
const rowTop = layout.y;
|
||||
const rowBottom = rowTop + layout.height;
|
||||
const visibleTop = nativeScrollY.current;
|
||||
const visibleBottom = visibleTop + nativeViewHeight.current;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
|
||||
} else if (rowBottom > visibleBottom) {
|
||||
resultsRef.current.scrollTo?.({
|
||||
y: rowBottom - nativeViewHeight.current,
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
|
||||
rowLayouts.current.set(rowIndex, {
|
||||
y: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
|
||||
const workspaceItems = useMemo(() => items.filter((item) => item.kind === "workspace"), [items]);
|
||||
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => [
|
||||
styles.panel,
|
||||
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
|
||||
],
|
||||
[theme.colors.border, theme.colors.surface0],
|
||||
);
|
||||
const headerStyle = useMemo(
|
||||
() => [styles.header, { borderBottomColor: theme.colors.border }],
|
||||
[theme.colors.border],
|
||||
);
|
||||
const inputStyle = useMemo(
|
||||
() => [styles.input, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const emptyTextStyle = useMemo(
|
||||
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const sectionLabelStyle = useMemo(
|
||||
() => [styles.sectionLabel, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const sectionDividerStyle = useMemo(
|
||||
() => [styles.sectionDivider, { backgroundColor: theme.colors.border }],
|
||||
[theme.colors.border],
|
||||
);
|
||||
const sheetBackgroundStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
const sheetHandleStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
handleKeyEvent(key);
|
||||
},
|
||||
[handleKeyEvent],
|
||||
);
|
||||
|
||||
const handleSubmitEditing = useCallback(() => {
|
||||
handleKeyEvent("Enter");
|
||||
}, [handleKeyEvent]);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={handleRowLayout(index)}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{workspaceItems.length > 0 ? (
|
||||
<WorkspaceItemsSection
|
||||
workspaceItems={workspaceItems}
|
||||
startIndex={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onRowLayout={handleRowLayout}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
startIndex={actionItems.length + workspaceItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onRowLayout={handleRowLayout}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile: bottom sheet
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={sheetBackgroundStyle}
|
||||
handleIndicatorStyle={sheetHandleStyle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef as unknown as React.Ref<never>}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Desktop web: centered overlay panel
|
||||
return (
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||
|
||||
<View testID="command-center-panel" style={panelStyle}>
|
||||
<View style={headerStyle}>
|
||||
<TextInput
|
||||
testID="command-center-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={resultsRef}
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
panel: {
|
||||
width: 640,
|
||||
maxWidth: "92%",
|
||||
maxHeight: "80%",
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
input: {
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
results: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
resultsContent: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionLabel: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: 0,
|
||||
paddingBottom: theme.spacing[2],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sectionDivider: {
|
||||
height: 1,
|
||||
marginTop: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
rowContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
iconSlot: {
|
||||
width: 16,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
rowShortcut: {
|
||||
marginLeft: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 18,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -62,6 +62,9 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
|
||||
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
|
||||
|
||||
interface AgentControlOption {
|
||||
id: string;
|
||||
@@ -130,6 +133,7 @@ export interface DraftAgentControlsProps {
|
||||
interface AgentControlsProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isPaneFocused: boolean;
|
||||
onDropdownClose?: () => void;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
@@ -1362,9 +1366,11 @@ function ThinkingComboboxOption({
|
||||
export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
isPaneFocused,
|
||||
onDropdownClose,
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
|
||||
@@ -1436,29 +1442,49 @@ export const AgentControls = memo(function AgentControls({
|
||||
const activeModelId = modelSelection.activeModelId;
|
||||
|
||||
const handleSelectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
async (modelId: string) => {
|
||||
if (!client || !agentProvider) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agentProvider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentControls] persist model preference failed", error);
|
||||
});
|
||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||
console.warn("[AgentControls] setAgentModel failed", error);
|
||||
try {
|
||||
await client.setAgentModel(agentId, modelId);
|
||||
await updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agentProvider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("[AgentControls] setAgentModel or persist preference failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}
|
||||
},
|
||||
[agentId, agentProvider, client, toast, updatePreferences],
|
||||
);
|
||||
|
||||
const commandCenterModelActions = useMemo(
|
||||
() =>
|
||||
buildModelChoiceContributions({
|
||||
serverId,
|
||||
providers: agentModelSelectorProviders,
|
||||
selectedProvider: agentProvider ?? null,
|
||||
selectedModelId: activeModelId,
|
||||
groupLabel: t("shell.commandCenter.modelGroupLabel"),
|
||||
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
|
||||
getIcon: getCommandCenterProviderIcon,
|
||||
select: (_provider, modelId) => handleSelectModel(modelId),
|
||||
}),
|
||||
[activeModelId, agentModelSelectorProviders, agentProvider, handleSelectModel, serverId, t],
|
||||
);
|
||||
useCommandCenterActions({
|
||||
sourceId: `agent:${serverId}:${agentId}`,
|
||||
enabled: isPaneFocused && Boolean(client),
|
||||
actions: commandCenterModelActions,
|
||||
});
|
||||
|
||||
const handleToggleFavoriteModel = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
void updatePreferences((current) =>
|
||||
|
||||
@@ -24,6 +24,9 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
|
||||
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
@@ -375,6 +378,28 @@ export function WorkspaceDraftAgentTab({
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace draft composer state is required");
|
||||
}
|
||||
|
||||
const draftModelActions = useMemo(
|
||||
() =>
|
||||
buildModelChoiceContributions({
|
||||
serverId,
|
||||
providers: composerState.modelSelectorProviders,
|
||||
selectedProvider: composerState.selectedProvider,
|
||||
selectedModelId: composerState.effectiveModelId || null,
|
||||
groupLabel: t("shell.commandCenter.modelGroupLabel"),
|
||||
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
|
||||
getIcon: getCommandCenterProviderIcon,
|
||||
select: composerState.setProviderAndModelFromUser,
|
||||
}),
|
||||
[
|
||||
composerState.effectiveModelId,
|
||||
composerState.modelSelectorProviders,
|
||||
composerState.selectedProvider,
|
||||
composerState.setProviderAndModelFromUser,
|
||||
serverId,
|
||||
t,
|
||||
],
|
||||
);
|
||||
const clearDraftInput = draftInput.clear;
|
||||
const setDraftText = draftInput.setText;
|
||||
const setDraftAttachments = draftInput.setAttachments;
|
||||
@@ -517,6 +542,11 @@ export function WorkspaceDraftAgentTab({
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
useCommandCenterActions({
|
||||
sourceId: `draft:${serverId}:${tabId}`,
|
||||
enabled: isPaneFocused && !isSubmitting,
|
||||
actions: draftModelActions,
|
||||
});
|
||||
|
||||
const isReadyForPendingAutoSubmit = Boolean(
|
||||
pendingAutoSubmit &&
|
||||
|
||||
@@ -258,10 +258,11 @@ interface RenderLeftContentArgs {
|
||||
serverId: string;
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
@@ -269,6 +270,7 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
@@ -1845,8 +1847,9 @@ export function Composer({
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, serverId],
|
||||
[agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
||||
const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = [];
|
||||
const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = [];
|
||||
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
|
||||
|
||||
function buildSearchText(...fields: string[]): string {
|
||||
return fields.join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function matchesQuery(searchText: string, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return !normalized || searchText.includes(normalized);
|
||||
}
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||
|
||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||
|
||||
const leftRunning = left.status === "running" ? 1 : 0;
|
||||
const rightRunning = right.status === "running" ? 1 : 0;
|
||||
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
interface CommandCenterActionDefinition {
|
||||
id: string;
|
||||
titleKey:
|
||||
| "shell.commandCenter.addProject"
|
||||
| "shell.commandCenter.home"
|
||||
| "sidebar.actions.settings";
|
||||
icon?: "plus" | "settings" | "home";
|
||||
actionId?: string;
|
||||
keywords: string[];
|
||||
routeKind: "settings" | "home" | "none";
|
||||
}
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
{
|
||||
id: "new-agent",
|
||||
titleKey: "shell.commandCenter.addProject",
|
||||
icon: "plus",
|
||||
actionId: "new-agent",
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
routeKind: "none",
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
titleKey: "shell.commandCenter.home",
|
||||
icon: "home",
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
routeKind: "home",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
titleKey: "sidebar.actions.settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
routeKind: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CommandCenterActionItem {
|
||||
kind: "action";
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings" | "home";
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterWorkspaceItem {
|
||||
kind: "workspace";
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterAgentItem {
|
||||
kind: "agent";
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export type CommandCenterItem =
|
||||
| CommandCenterActionItem
|
||||
| CommandCenterWorkspaceItem
|
||||
| CommandCenterAgentItem;
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[][] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : undefined;
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const openAddProject = useOpenAddProject();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const didNavigateRef = useRef(false);
|
||||
const prevOpenRef = useRef(open);
|
||||
const activeIndexRef = useRef(0);
|
||||
const itemsRef = useRef<CommandCenterItem[]>([]);
|
||||
const handleCloseRef = useRef<() => void>(() => undefined);
|
||||
const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const { agents } = useAggregatedAgents();
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const hosts = useHosts();
|
||||
const showAgentHost = hosts.length > 1;
|
||||
|
||||
const allWorkspaceItems = useMemo(() => {
|
||||
const results: CommandCenterWorkspaceItem[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
const title = workspace.title ?? workspace.name;
|
||||
const subtitle = workspace.currentBranch
|
||||
? `${host.serverName} · ${workspace.currentBranch}`
|
||||
: host.serverName;
|
||||
results.push({
|
||||
kind: "workspace",
|
||||
serverId: host.serverId,
|
||||
workspaceId: workspace.id,
|
||||
title,
|
||||
subtitle,
|
||||
searchText: buildSearchText(title, subtitle),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort((left, right) => {
|
||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (titleDelta !== 0) return titleDelta;
|
||||
const hostDelta = left.subtitle.localeCompare(right.subtitle, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (hostDelta !== 0) return hostDelta;
|
||||
return `${left.serverId}:${left.workspaceId}`.localeCompare(
|
||||
`${right.serverId}:${right.workspaceId}`,
|
||||
);
|
||||
});
|
||||
return results;
|
||||
}, [projects]);
|
||||
|
||||
const workspaceTitleByKey = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
allWorkspaceItems.map((workspace) => [
|
||||
`${workspace.serverId}:${workspace.workspaceId}`,
|
||||
workspace.title,
|
||||
]),
|
||||
),
|
||||
[allWorkspaceItems],
|
||||
);
|
||||
|
||||
const workspaceResults = useMemo(() => {
|
||||
if (!open || allWorkspaceItems.length === 0) {
|
||||
return EMPTY_WORKSPACE_ITEMS;
|
||||
}
|
||||
return allWorkspaceItems.filter((workspace) => matchesQuery(workspace.searchText, query));
|
||||
}, [allWorkspaceItems, open, query]);
|
||||
|
||||
const agentResults = useMemo(() => {
|
||||
if (!open || agents.length === 0) {
|
||||
return EMPTY_AGENT_ITEMS;
|
||||
}
|
||||
const items = agents.map<CommandCenterAgentItem>((agent) => {
|
||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
||||
const subtitle = [
|
||||
showAgentHost ? agent.serverLabel : null,
|
||||
location,
|
||||
formatTimeAgo(agent.lastActivityAt),
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(" · ");
|
||||
return {
|
||||
kind: "agent",
|
||||
agent,
|
||||
title,
|
||||
subtitle,
|
||||
searchText: buildSearchText(title, subtitle, agent.cwd),
|
||||
};
|
||||
});
|
||||
const filtered = items.filter((item) => matchesQuery(item.searchText, query));
|
||||
filtered.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
return filtered;
|
||||
}, [agents, open, query, showAgentHost, t, workspaceTitleByKey]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
return buildSettingsRoute();
|
||||
}, []);
|
||||
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_ACTION_ITEMS;
|
||||
}
|
||||
return COMMAND_CENTER_ACTIONS.filter(
|
||||
(action) => action.routeKind !== "home" || Boolean(homeRoute),
|
||||
)
|
||||
.map<CommandCenterActionItem>((action) => {
|
||||
let route: Href | undefined;
|
||||
if (action.routeKind === "settings") route = settingsRoute;
|
||||
else if (action.routeKind === "home") route = homeRoute;
|
||||
const title = t(action.titleKey);
|
||||
return {
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title,
|
||||
icon: action.icon,
|
||||
route,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
searchText: buildSearchText(title, ...action.keywords),
|
||||
};
|
||||
})
|
||||
.filter((action) => matchesQuery(action.searchText, query));
|
||||
}, [open, query, settingsRoute, homeRoute, overrides, t]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_COMMAND_CENTER_ITEMS;
|
||||
}
|
||||
return [...actionItems, ...workspaceResults, ...agentResults];
|
||||
}, [actionItems, workspaceResults, agentResults, open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
didNavigateRef.current = true;
|
||||
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigateToAgent({
|
||||
serverId: agent.serverId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
(workspace: CommandCenterWorkspaceItem) => {
|
||||
didNavigateRef.current = true;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigateToWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback(
|
||||
(action: CommandCenterActionItem) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
if (action.id === "new-agent") {
|
||||
openAddProject();
|
||||
return;
|
||||
}
|
||||
if (!action.route) {
|
||||
return;
|
||||
}
|
||||
didNavigateRef.current = true;
|
||||
router.push(action.route);
|
||||
},
|
||||
[openAddProject, setOpen],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
if (item.kind === "action") {
|
||||
handleSelectAction(item);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "workspace") {
|
||||
handleSelectWorkspace(item);
|
||||
return;
|
||||
}
|
||||
handleSelectAgent(item.agent);
|
||||
},
|
||||
[handleSelectAction, handleSelectAgent, handleSelectWorkspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = handleClose;
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
handleSelectItemRef.current = handleSelectItem;
|
||||
}, [handleSelectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevOpen = prevOpenRef.current;
|
||||
prevOpenRef.current = open;
|
||||
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
|
||||
if (prevOpen && !didNavigateRef.current) {
|
||||
const el = takeCommandCenterFocusRestoreElement();
|
||||
const isFocused = () =>
|
||||
Boolean(el) && typeof document !== "undefined" && document.activeElement === el;
|
||||
|
||||
const cancel = focusWithRetries({
|
||||
focus: () => el?.focus(),
|
||||
isFocused,
|
||||
onTimeout: () => {
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
});
|
||||
},
|
||||
});
|
||||
return cancel;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
didNavigateRef.current = false;
|
||||
|
||||
const id = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (activeIndex >= items.length) {
|
||||
setActiveIndex(items.length > 0 ? items.length - 1 : 0);
|
||||
}
|
||||
}, [activeIndex, items.length, open]);
|
||||
|
||||
const handleKeyEvent = useCallback(
|
||||
(key: string): boolean => {
|
||||
if (!open) return false;
|
||||
const currentItems = itemsRef.current;
|
||||
|
||||
if (key === "Escape") {
|
||||
handleCloseRef.current();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (currentItems.length === 0) return false;
|
||||
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
||||
handleSelectItemRef.current(currentItems[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (currentItems.length === 0) return false;
|
||||
setActiveIndex((current) => {
|
||||
const delta = key === "ArrowDown" ? 1 : -1;
|
||||
const next = current + delta;
|
||||
if (next < 0) return currentItems.length - 1;
|
||||
if (next >= currentItems.length) return 0;
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isWeb) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
event.key !== "Enter" &&
|
||||
event.key !== "Escape"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (handleKeyEvent(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [open, handleKeyEvent]);
|
||||
|
||||
return {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
};
|
||||
}
|
||||
@@ -184,6 +184,10 @@ describe("translation resources", () => {
|
||||
expect(en.shell.commandCenter.newAgent).toBe("New agent");
|
||||
expect(en.shell.commandCenter.addProject).toBe("Add project");
|
||||
expect(en.shell.commandCenter.home).toBe("Home");
|
||||
expect(en.shell.commandCenter.modelGroupLabel).toBe("Model");
|
||||
expect(en.shell.commandCenter.modelSearchKeywords).toBe(
|
||||
"switch model change model set model select model",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes composer and agent workflow keys for the Batch 2 migration", () => {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const ar: TranslationResources = {
|
||||
newAgent: "وكيل جديد",
|
||||
addProject: "إضافة مشروع",
|
||||
home: "بيت",
|
||||
modelGroupLabel: "النموذج",
|
||||
modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -60,6 +60,8 @@ export const en = {
|
||||
newAgent: "New agent",
|
||||
addProject: "Add project",
|
||||
home: "Home",
|
||||
modelGroupLabel: "Model",
|
||||
modelSearchKeywords: "switch model change model set model select model",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const es: TranslationResources = {
|
||||
newAgent: "Nuevo agente",
|
||||
addProject: "Agregar proyecto",
|
||||
home: "Hogar",
|
||||
modelGroupLabel: "Modelo",
|
||||
modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -63,6 +63,9 @@ export const fr: TranslationResources = {
|
||||
newAgent: "Nouvel agent",
|
||||
addProject: "Ajouter un projet",
|
||||
home: "Maison",
|
||||
modelGroupLabel: "Modèle",
|
||||
modelSearchKeywords:
|
||||
"changer de modèle modifier le modèle définir le modèle sélectionner le modèle",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const ja: TranslationResources = {
|
||||
newAgent: "新しいエージェント",
|
||||
addProject: "プロジェクトを追加",
|
||||
home: "ホーム",
|
||||
modelGroupLabel: "モデル",
|
||||
modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const ptBR: TranslationResources = {
|
||||
newAgent: "Novo agente",
|
||||
addProject: "Adicionar projeto",
|
||||
home: "Início",
|
||||
modelGroupLabel: "Modelo",
|
||||
modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const ru: TranslationResources = {
|
||||
newAgent: "Новый агент",
|
||||
addProject: "Добавить проект",
|
||||
home: "Дом",
|
||||
modelGroupLabel: "Модель",
|
||||
modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const zhCN: TranslationResources = {
|
||||
newAgent: "新建 Agent",
|
||||
addProject: "添加 project",
|
||||
home: "首页",
|
||||
modelGroupLabel: "模型",
|
||||
modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型",
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
|
||||
Reference in New Issue
Block a user