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: {