From 45bbb973a3a3d4b386b67f219dd2944b19c369c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99drzy=C5=84ski?= Date: Sat, 18 Jul 2026 16:54:41 +0200 Subject: [PATCH] Switch models from the Command Center (#2147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- packages/app/src/app/_layout.tsx | 6 +- .../app/src/command-center/command-center.tsx | 792 ++++++++++++++++++ .../app/src/command-center/contributions.ts | 53 ++ .../model-contributions.test.tsx | 104 +++ .../src/command-center/model-contributions.ts | 51 ++ .../app/src/command-center/provider-icon.tsx | 20 + packages/app/src/command-center/provider.tsx | 56 ++ .../app/src/command-center/registry.test.ts | 73 ++ packages/app/src/command-center/registry.ts | 100 +++ .../app/src/command-center/results.test.ts | 105 +++ packages/app/src/command-center/results.ts | 181 ++++ .../app/src/components/command-center.tsx | 726 ---------------- .../app/src/composer/agent-controls/index.tsx | 56 +- .../app/src/composer/draft/workspace-tab.tsx | 30 + packages/app/src/composer/index.tsx | 7 +- packages/app/src/hooks/use-command-center.ts | 482 ----------- packages/app/src/i18n/resources.test.ts | 4 + packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + 25 files changed, 1636 insertions(+), 1227 deletions(-) create mode 100644 packages/app/src/command-center/command-center.tsx create mode 100644 packages/app/src/command-center/contributions.ts create mode 100644 packages/app/src/command-center/model-contributions.test.tsx create mode 100644 packages/app/src/command-center/model-contributions.ts create mode 100644 packages/app/src/command-center/provider-icon.tsx create mode 100644 packages/app/src/command-center/provider.tsx create mode 100644 packages/app/src/command-center/registry.test.ts create mode 100644 packages/app/src/command-center/registry.ts create mode 100644 packages/app/src/command-center/results.test.ts create mode 100644 packages/app/src/command-center/results.ts delete mode 100644 packages/app/src/components/command-center.tsx delete mode 100644 packages/app/src/hooks/use-command-center.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 16aa4b0f6..2f7fcd2fd 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 + @@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); - return content; + return {content}; } function SidebarChrome({ diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx new file mode 100644 index 000000000..584c80bfc --- /dev/null +++ b/packages/app/src/command-center/command-center.tsx @@ -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 ; +} + +function SettingsIcon({ size }: CommandCenterIconProps) { + return ; +} + +function HomeIcon({ size }: CommandCenterIconProps) { + return ; +} + +function resolveActionShortcutKeys( + actionId: string | undefined, + overrides: Record, +): 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(() => buildSettingsRoute(), []); + const homeRoute = useMemo(() => buildOpenProjectRoute(), []); + const actions = useMemo( + () => [ + { + 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((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; + offsets: readonly number[]; + inputRef: React.RefObject; + 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(null); + const previousOpenRef = useRef(open); + const [query, setQuery] = useState(""); + const [activeId, setActiveId] = useState(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 ( + + + + ); +}); + +function ResultContent({ result }: { result: CommandCenterResult }) { + if (result.kind === "agent") { + const agent = result.agent; + return ( + + + + + + + + {result.title} + + + {result.subtitle} + + + + + ); + } + if (result.kind === "workspace") { + const key = result.id.slice("workspace:".length); + return ( + + + + + + + + {result.title} + + + {result.subtitle} + + + + + ); + } + const presentation = result.contribution.presentation; + const Icon = presentation.icon; + if (presentation.kind === "action") { + return ( + + + {Icon ? ( + + + + ) : null} + + + {presentation.title} + + {presentation.subtitle ? ( + {presentation.subtitle} + ) : null} + + + {presentation.shortcutKeys ? ( + + ) : null} + + ); + } + return ( + + + {Icon ? ( + + + + ) : null} + + {presentation.path.map((part, index) => ( + + {index > 0 ? : null} + + {part} + + + ))} + + + {presentation.selected ? ( + + + + ) : null} + + ); +} + +function SectionRow({ row }: { row: Extract }) { + let sizeStyle = styles.dividerSection; + if (row.title && row.divider) sizeStyle = styles.dividedSection; + if (row.title && !row.divider) sizeStyle = styles.titledSection; + return ( + + {row.divider ? : null} + {row.title ? {row.title} : null} + + ); +} + +export function CommandCenter() { + const { t } = useTranslation(); + const state = useCommandCenterState(); + const isCompact = useIsCompactFormFactor(); + const showBottomSheet = isCompact && isNative; + const listRef = useRef>(null); + const bottomSheetListRef = useRef(null); + const bottomSheetInputRef = useRef>(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) => + item.kind === "section" ? ( + + ) : ( + + ), + [state.activeId, state.select], + ); + const getItemLayout = useCallback( + (_data: ArrayLike | 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( + () => {t("shell.commandCenter.noMatches")}, + [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) => ( + + ), + [], + ); + + if (showBottomSheet) { + return ( + + + + + + + ); + } + if (!state.open) return null; + return ( + + + + + + + + + + + + ); +} + +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" }, +})); diff --git a/packages/app/src/command-center/contributions.ts b/packages/app/src/command-center/contributions.ts new file mode 100644 index 000000000..ab54cbc7e --- /dev/null +++ b/packages/app/src/command-center/contributions.ts @@ -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; + +interface CommandCenterContributionBase { + id: string; + group: string; + groupRank: number; + rank: number; + keywords: readonly string[]; + visibility: "always" | "query"; + run(): void | Promise; +} + +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[]; +} diff --git a/packages/app/src/command-center/model-contributions.test.tsx b/packages/app/src/command-center/model-contributions.test.tsx new file mode 100644 index 000000000..6e38b33e1 --- /dev/null +++ b/packages/app/src/command-center/model-contributions.test.tsx @@ -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([]); + }); +}); diff --git a/packages/app/src/command-center/model-contributions.ts b/packages/app/src/command-center/model-contributions.ts new file mode 100644 index 000000000..182a528c9 --- /dev/null +++ b/packages/app/src/command-center/model-contributions.ts @@ -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; +} diff --git a/packages/app/src/command-center/provider-icon.tsx b/packages/app/src/command-center/provider-icon.tsx new file mode 100644 index 000000000..245d5de27 --- /dev/null +++ b/packages/app/src/command-center/provider-icon.tsx @@ -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(); + +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 ; + } + commandCenterProviderIcons.set(provider, CommandCenterProviderIcon); + return CommandCenterProviderIcon; +} diff --git a/packages/app/src/command-center/provider.tsx b/packages/app/src/command-center/provider.tsx new file mode 100644 index 000000000..2f1cc706c --- /dev/null +++ b/packages/app/src/command-center/provider.tsx @@ -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(null); + +export function CommandCenterProvider({ children }: { children: ReactNode }) { + const registryRef = useRef(null); + if (!registryRef.current) registryRef.current = createCommandCenterRegistry(); + + return ( + + {children} + + ); +} + +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]); +} diff --git a/packages/app/src/command-center/registry.test.ts b/packages/app/src/command-center/registry.test.ts new file mode 100644 index 000000000..cf8d60684 --- /dev/null +++ b/packages/app/src/command-center/registry.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/command-center/registry.ts b/packages/app/src/command-center/registry.ts new file mode 100644 index 000000000..f4c383621 --- /dev/null +++ b/packages/app/src/command-center/registry.ts @@ -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(); + const listeners = new Set<() => void>(); + let snapshot = EMPTY_SNAPSHOT; + + function publish(): void { + const contributions: CommandCenterContribution[] = []; + const ids = new Set(); + + 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(); + 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(); + }, + }; +} diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts new file mode 100644 index 000000000..3405a6a51 --- /dev/null +++ b/packages/app/src/command-center/results.test.ts @@ -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): 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); + }); +}); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts new file mode 100644 index 000000000..ab2f3a837 --- /dev/null +++ b/packages/app/src/command-center/results.ts @@ -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; +} + +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; + 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(); + 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(); + 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; +} diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx deleted file mode 100644 index 49cafb624..000000000 --- a/packages/app/src/components/command-center.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -}); - -interface CommandCenterRowContainerProps { - item: CommandCenterItem; - rowIndex: number; - active: boolean; - rowRefs: React.MutableRefObject>; - 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 ( - - {children} - - ); -} - -interface CommandCenterActionRowProps { - item: CommandCenterActionItem; - rowIndex: number; - active: boolean; - rowRefs: React.MutableRefObject>; - 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 = ; - } else if (item.icon === "settings") { - actionIcon = ; - } else if (item.icon === "home") { - actionIcon = ; - } - const titleStyle = useMemo( - () => [styles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - return ( - - - - {actionIcon ? {actionIcon} : null} - - - {item.title} - - - - {item.shortcutKeys ? ( - - ) : null} - - - ); -} - -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 ( - - - - - - - - {item.title} - - - {item.subtitle} - - - - - ); -} - -interface AgentItemsSectionProps { - agentItems: CommandCenterAgentItem[]; - startIndex: number; - activeIndex: number; - rowRefs: React.MutableRefObject>; - onRowLayout: ( - rowIndex: number, - ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: CommandCenterItem) => void; - sectionDividerStyle: React.ComponentProps["style"]; - sectionLabelStyle: React.ComponentProps["style"]; -} - -function AgentItemsSection({ - agentItems, - startIndex, - activeIndex, - rowRefs, - onRowLayout, - onSelect, - sectionDividerStyle, - sectionLabelStyle, -}: AgentItemsSectionProps) { - const { t } = useTranslation(); - - return ( - <> - {startIndex > 0 ? : null} - {t("shell.commandCenter.agents")} - {agentItems.map((item, index) => { - const rowIndex = startIndex + index; - const agent = item.agent; - return ( - - - - ); - })} - - ); -} - -interface WorkspaceItemsSectionProps { - workspaceItems: CommandCenterWorkspaceItem[]; - startIndex: number; - activeIndex: number; - rowRefs: React.MutableRefObject>; - onRowLayout: ( - rowIndex: number, - ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: CommandCenterItem) => void; - sectionDividerStyle: React.ComponentProps["style"]; - sectionLabelStyle: React.ComponentProps["style"]; -} - -function WorkspaceItemsSection({ - workspaceItems, - startIndex, - activeIndex, - rowRefs, - onRowLayout, - onSelect, - sectionDividerStyle, - sectionLabelStyle, -}: WorkspaceItemsSectionProps) { - const { t } = useTranslation(); - - return ( - <> - {startIndex > 0 ? : null} - {t("shell.commandCenter.workspaces")} - {workspaceItems.map((item, index) => { - const rowIndex = startIndex + index; - return ( - - - - - - - - - {item.title} - - - {item.subtitle} - - - - - - ); - })} - - ); -} - -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>(new Map()); - const rowLayouts = useRef>(new Map()); - const resultsRef = useRef(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(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) => ( - - ), - [], - ); - - // 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 ? ( - {t("shell.commandCenter.noMatches")} - ) : ( - <> - {actionItems.length > 0 ? ( - <> - {t("shell.commandCenter.actions")} - {actionItems.map((item, index) => ( - - ))} - - ) : null} - - {workspaceItems.length > 0 ? ( - - ) : null} - - {agentItems.length > 0 ? ( - - ) : null} - - ); - - // Mobile: bottom sheet - if (showBottomSheet) { - return ( - - - } - value={query} - onChangeText={setQuery} - onKeyPress={handleKeyPress} - onSubmitEditing={handleSubmitEditing} - placeholder={t("shell.commandCenter.placeholder")} - style={inputStyle} - autoCapitalize="none" - autoCorrect={false} - autoFocus - /> - - - {resultList} - - - ); - } - - if (!open) return null; - - // Desktop web: centered overlay panel - return ( - - - - - - - - - - - {resultList} - - - - - ); -} - -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, - }, -})); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 6e4024e39..a7ca3f624 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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) => diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4398448cc..3ac357fb8 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 && diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d214d794b..6faa7a5e5 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ; } @@ -269,6 +270,7 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement { @@ -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) => { diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts deleted file mode 100644 index 682c254ba..000000000 --- a/packages/app/src/hooks/use-command-center.ts +++ /dev/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, -): 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(null); - const didNavigateRef = useRef(false); - const prevOpenRef = useRef(open); - const activeIndexRef = useRef(0); - const itemsRef = useRef([]); - 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((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(() => { - return buildSettingsRoute(); - }, []); - - const homeRoute = useMemo(() => buildOpenProjectRoute() as Href, []); - - const actionItems = useMemo(() => { - if (!open) { - return EMPTY_ACTION_ITEMS; - } - return COMMAND_CENTER_ACTIONS.filter( - (action) => action.routeKind !== "home" || Boolean(homeRoute), - ) - .map((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, - }; -} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index d412bd8d1..124c6643e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -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", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9ca62a417..703221824 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -62,6 +62,8 @@ export const ar: TranslationResources = { newAgent: "وكيل جديد", addProject: "إضافة مشروع", home: "بيت", + modelGroupLabel: "النموذج", + modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج", }, }, composer: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 147b24beb..acc593ce4 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 72a7cef63..73bdc0c7f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ae70cba91..c3b58ff75 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 61f878696..b7528bf08 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -62,6 +62,8 @@ export const ja: TranslationResources = { newAgent: "新しいエージェント", addProject: "プロジェクトを追加", home: "ホーム", + modelGroupLabel: "モデル", + modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択", }, }, composer: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 45d07d737..d474e3019 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f0f2bb207..4ef336f8e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -62,6 +62,8 @@ export const ru: TranslationResources = { newAgent: "Новый агент", addProject: "Добавить проект", home: "Дом", + modelGroupLabel: "Модель", + modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель", }, }, composer: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 24650d1b9..347d4585d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -62,6 +62,8 @@ export const zhCN: TranslationResources = { newAgent: "新建 Agent", addProject: "添加 project", home: "首页", + modelGroupLabel: "模型", + modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型", }, }, composer: {