1"use client";
2
3import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
4import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
5
6import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
7import {
8 canCreateProjectInEnvironment,
9 getCloneDestinationBrowsePath,
10 getCloneDestinationPath,
11 getCloneDirectoryName,
12 getDefaultCloneUrl,
13 getNewProjectPathPreview,
14 normalizePastedCloneUrl,
15 addProjectRemoteSourceLabel,
16 addProjectRemoteSourcePathHint,
17 addProjectRemoteSourceProvider,
18 buildAddProjectRemoteSourceReadiness,
19 sortAddProjectProviderSources,
20 type AddProjectRemoteSource,
21 type AddProjectRemoteSourceReadiness,
22} from "@t3tools/client-runtime/operations/projects";
23import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
24import { connectionStatusText } from "@t3tools/client-runtime/connection";
25import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
26import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
27import {
28 canPreloadBrowsePath,
29 createBrowseNavigationCoordinator,
30 filterFilesystemBrowseEntries,
31 getFilesystemBrowsePath,
32} from "@t3tools/client-runtime/state/filesystem";
33import {
34 isAtomCommandInterrupted,
35 settlePromise,
36 squashAtomCommandFailure,
37} from "@t3tools/client-runtime/state/runtime";
38import {
39 AuthOrchestrationOperateScope,
40 AuthSourceControlWriteScope,
41 AuthFilesystemReadScope,
42 type DesktopWslState,
43 type EnvironmentId,
44 type EnvironmentMachineKind,
45 type FilesystemBrowseResult,
46 type ProjectId,
47 type SourceControlDiscoveryResult,
48 type SourceControlRepositoryInfo,
49 PRIMARY_LOCAL_ENVIRONMENT_ID,
50 resolveEnvironmentMachineKind,
51} from "@t3tools/contracts";
52import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
53import {
54 ArrowLeftIcon,
55 ChartNoAxesColumnIcon,
56 CheckIcon,
57 ChevronRightIcon,
58 CornerLeftUpIcon,
59 FileSearchIcon,
60 FolderIcon,
61 FolderPlusIcon,
62 MessageSquareDashedIcon,
63 LinkIcon,
64 MessageSquareIcon,
65 MonitorIcon,
66 MoonIcon,
67 PaletteIcon,
68 RotateCcwIcon,
69 SettingsIcon,
70 SquarePenIcon,
71 SunIcon,
72 TextSearchIcon,
73} from "lucide-react";
74import { requestThreadFindOpen } from "./chat/threadFindActionBus";
75import {
76 useCallback,
77 useDeferredValue,
78 useEffect,
79 useLayoutEffect,
80 useMemo,
81 useReducer,
82 useRef,
83 useState,
84 type KeyboardEvent,
85 type ReactNode,
86} from "react";
87import type { LegendListRef } from "@legendapp/list/react";
88import { useAtomValue } from "@effect/atom-react";
89
90import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
91import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
92import { useHandleNewThread } from "../hooks/useHandleNewThread";
93import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
94import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
95import {
96 composerPromptLink,
97 copyPromptLink,
98 promptLinkRepositoryOf,
99} from "./promptLinks/copyPromptLink";
100import { useClientSettings } from "../hooks/useSettings";
101import { useTheme } from "../hooks/useTheme";
102import { useCustomThemes } from "../hooks/useCustomThemes";
103import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme";
104import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
105import { getThemeDefinition } from "../themePalette";
106import {
107 STANDARD_THEME_CARDS,
108 getThemeCardDefinition,
109 ThemePreviewCircle,
110} from "./settings/ThemePreviewCircles";
111import { readLocalApi } from "../localApi";
112import { desktopLocalBackendId } from "../connection/desktopLocal";
113import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem";
114import { projectEnvironment } from "../state/projects";
115import { useEnvironmentQuery } from "../state/query";
116import { serverEnvironment } from "../state/server";
117import { threadEnvironment } from "../state/threads";
118import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
119import { sourceControlEnvironment } from "../state/sourceControl";
120import { useAtomCommand } from "../state/use-atom-command";
121import { useAtomQueryRunner } from "../state/use-atom-query-runner";
122import { useNewWorkWorkspace } from "../hooks/useNewWorkWorkspace";
123import { useScratchProject } from "../hooks/useScratchProject";
124import { isScratchProject } from "@t3tools/client-runtime/state/projects";
125import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
126import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
127import { useThreadSearch } from "../state/queries";
128import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
129import {
130 appendBrowsePathSegment,
131 ensureBrowseDirectoryPath,
132 findProjectByPath,
133 getBrowseDirectoryPath,
134 hasTrailingPathSeparator,
135 inferProjectTitleFromPath,
136 isExplicitRelativeProjectPath,
137 isUnsupportedWindowsProjectPath,
138 resolveProjectPathForDispatch,
139} from "../lib/projectPaths";
140import { onOpenCommandPalette } from "../commandPaletteBus";
141import { isPreviewFocused } from "../lib/previewFocus";
142import { isTerminalFocused } from "../lib/terminalFocus";
143import {
144 PULL_REQUESTS_PANEL_REF,
145 selectActiveRightPanel,
146 useRightPanelStore,
147} from "../rightPanelStore";
148import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
149import {
150 cn,
151 getLocalFileManagerName,
152 isMacPlatform,
153 isWindowsPlatform,
154 newProjectId,
155} from "../lib/utils";
156import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
157import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
158import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems";
159import {
160 applyWslEnvironmentConfiguration,
161 parseWslUncPath,
162 resolveProjectPickerTarget,
163 resolveWslProjectSelection,
164} from "../wslPaths";
165import {
166 ADDON_ICON_CLASS,
167 browseInputEndPaddingClass,
168 buildBrowseGroups,
169 buildCommandPaletteProjectMetadata,
170 buildProjectActionItems,
171 buildRootGroups,
172 buildThreadActionItems,
173 buildLinkedThreadActionItems,
174 buildCommandPaletteRows,
175 enumerateCommandPaletteItems,
176 findHighlightedCommandPaletteItem,
177 type CommandPaletteActionItem,
178 type CommandPaletteOpenIntent,
179 type CommandPaletteProject,
180 type CommandPaletteSubmenuItem,
181 type CommandPaletteView,
182 filterCommandPaletteGroups,
183 filterPinnedBrowseEntries,
184 getCommandPaletteInputPlaceholder,
185 getCommandPaletteMode,
186 ITEM_ICON_CLASS,
187 RECENT_THREAD_LIMIT,
188 reduceCommandPaletteUiState,
189 type SearchOverlayMode,
190} from "./CommandPalette.logic";
191import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
192import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
193import { CommandPaletteContent } from "./CommandPaletteContent";
194import {
195 CommandPaletteVirtualizedResults,
196 scrollCommandPaletteRowIntoView,
197} from "./CommandPaletteResults";
198import { sourceControlIcon } from "~/sourceControlPresentation";
199import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
200import { ProjectFavicon } from "./ProjectFavicon";
201import { ProjectFilePicker } from "./files/ProjectFilePicker";
202import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
203import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
204import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
205import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
206import {
207 COMMAND_PALETTE_META_ICON_CLASS,
208 CommandPaletteMetaDot,
209 ThreadCommandSubtitle,
210} from "./ThreadCommandSubtitle";
211import {
212 ThreadRowLeadingStatus,
213 ThreadRowTrailingStatus,
214 ThreadSearchPullRequestNumber,
215} from "./ThreadStatusIndicators";
216import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
217import {
218 applyProviderInstanceSettings,
219 deriveProviderInstanceEntries,
220 type ProviderInstanceEntry,
221} from "../providerInstances";
222import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
223import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
224import { Button } from "./ui/button";
225import { Kbd, KbdGroup } from "./ui/kbd";
226import { stackedThreadToast, toastManager } from "./ui/toast";
227import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
228import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
229import type { ChatComposerHandle } from "./chat/ChatComposer";
230import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
231import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
232import {
233 buildSidebarProjectPickerEntries,
234 buildSidebarProjectSnapshots,
235} from "../sidebarProjectGrouping";
236import type { Project } from "../types";
237import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
238import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences";
239
240const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
241
242function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
243 if (os === "windows") {
244 return "Win32";
245 }
246 if (os === "darwin") {
247 return "MacIntel";
248 }
249 if (os === "linux") {
250 return "Linux";
251 }
252 return typeof navigator === "undefined" ? "" : navigator.platform;
253}
254
255interface AddProjectEnvironmentOption {
256 readonly environmentId: EnvironmentId;
257 readonly label: string;
258 readonly machine: EnvironmentMachineKind;
259 readonly isPrimary: boolean;
260 readonly isConnected: boolean;
261 readonly status: string;
262}
263
264type AddProjectCloneFlow =
265 | {
266 readonly step: "repository";
267 readonly environmentId: EnvironmentId;
268 readonly source: AddProjectRemoteSource;
269 }
270 | {
271 readonly step: "confirm";
272 readonly environmentId: EnvironmentId;
273 readonly source: AddProjectRemoteSource;
274 readonly repositoryInput: string;
275 readonly repository: SourceControlRepositoryInfo | null;
276 readonly remoteUrl: string;
277 };
278
279function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode {
280 if (source === "url") return <LinkIcon className={className} />;
281 const Icon = sourceControlIcon(sourceControlClients.get(source));
282 return <Icon className={className} />;
283}
284
285function projectFaviconIcon(project: Project): ReactNode {
286 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;
287}
288
289function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null {
290 if (!flow) return null;
291 if (flow.step === "confirm") return null;
292 if (flow.source === "url") {
293 return "Enter Git clone URL";
294 }
295 return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`;
296}
297
298/** The palette's readiness, pointing at Settings by its menu path. */
299function buildPaletteRemoteSourceReadiness(
300 discovery: SourceControlDiscoveryResult | null,
301): AddProjectRemoteSourceReadiness {
302 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
303 return (source) => {
304 const entry = readiness(source);
305 return entry.hint === null
306 ? entry
307 : {
308 ...entry,
309 hint: entry.hint.replace(
310 "Open Source Control settings",
311 "Open Settings -> Source Control",
312 ),
313 };
314 };
315}
316
317function errorMessage(error: unknown): string {
318 if (error instanceof Error && error.message.trim().length > 0) {
319 return error.message;
320 }
321 return "An error occurred.";
322}
323
324const OVERLAY_MODE_BY_COMMAND = {
325 "commandPalette.toggle": "command",
326 "filePicker.toggle": "files",
327 "projectSearch.toggle": "content",
328} as const satisfies Partial<Record<string, SearchOverlayMode>>;
329
330function overlayModeForCommand(command: string | null): SearchOverlayMode | null {
331 if (command === null) return null;
332 return command in OVERLAY_MODE_BY_COMMAND
333 ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND]
334 : null;
335}
336
337const APPEARANCE_OPTIONS = [
338 { mode: "system", label: "System", icon: MonitorIcon },
339 { mode: "light", label: "Light", icon: SunIcon },
340 { mode: "dark", label: "Dark", icon: MoonIcon },
341] as const;
342
343function notifyThemeSaveFailure(): void {
344 toastManager.add(
345 stackedThreadToast({
346 type: "error",
347 title: "Couldn't save theme selection",
348 description: "Try again.",
349 }),
350 );
351}
352
353export function CommandPalette({ children }: { children: ReactNode }) {
354 const navigate = useNavigate();
355 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
356 open: false,
357 mode: "command",
358 openIntent: null,
359 });
360 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
361 const toggleMode = useCallback(
362 (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }),
363 [],
364 );
365 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
366 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
367 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
368 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
369 const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme();
370 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
371 const routeTarget = useParams({
372 strict: false,
373 select: (params) => resolveThreadRouteTarget(params),
374 });
375 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
376 const terminalOpen = useTerminalUiStateStore((state) =>
377 routeThreadRef
378 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
379 : false,
380 );
381 const previewOpen = useRightPanelStore((state) =>
382 routeThreadRef
383 ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
384 : false,
385 );
386
387 useEffect(() => {
388 if (!state.open || state.mode === "command") return;
389 const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => {
390 if (event.isComposing || event.key !== "Escape") return;
391 event.preventDefault();
392 event.stopPropagation();
393 toggleMode("command");
394 };
395 window.addEventListener("keydown", onEscapeKeyDown, true);
396 return () => window.removeEventListener("keydown", onEscapeKeyDown, true);
397 }, [state.mode, state.open, toggleMode]);
398
399 useEffect(() => {
400 const onKeyDown = (event: globalThis.KeyboardEvent) => {
401 if (event.defaultPrevented) return;
402 // Resolve with the complete shortcut context so customized bindings
403 // using any documented `when` condition (e.g. previewFocus) work.
404 const command = resolveShortcutCommand(event, keybindings, {
405 context: {
406 terminalFocus: isTerminalFocused(),
407 terminalOpen,
408 previewFocus: isPreviewFocused(),
409 previewOpen,
410 modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false,
411 },
412 });
413 if (command === "appearance.cycle") {
414 event.preventDefault();
415 event.stopPropagation();
416 if (event.repeat) return;
417 const nextMode =
418 appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system";
419 if (!setAppearanceMode(nextMode)) {
420 notifyThemeSaveFailure();
421 } else {
422 toastManager.add({
423 id: "appearance-cycle",
424 title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`,
425 timeout: 1500,
426 });
427 }
428 return;
429 }
430 if (command === "theme.select") {
431 event.preventDefault();
432 event.stopPropagation();
433 if (event.repeat) return;
434 dispatch({ _tag: "OpenChangeTheme" });
435 return;
436 }
437 if (command === "themeEditor.toggle") {
438 event.preventDefault();
439 event.stopPropagation();
440 toggleThemeEditorForTheme({
441 theme,
442 themeHalves,
443 initialAppearance: resolvedTheme,
444 });
445 return;
446 }
447 if (command === "usage.open") {
448 event.preventDefault();
449 event.stopPropagation();
450 setOpen(false);
451 void navigate({ to: "/usage" });
452 return;
453 }
454 const mode = overlayModeForCommand(command);
455 if (mode === null) {
456 return;
457 }
458 event.preventDefault();
459 event.stopPropagation();
460 toggleMode(mode);
461 };
462 window.addEventListener("keydown", onKeyDown);
463 return () => window.removeEventListener("keydown", onKeyDown);
464 }, [
465 appearanceMode,
466 keybindings,
467 navigate,
468 previewOpen,
469 resolvedTheme,
470 setAppearanceMode,
471 setOpen,
472 terminalOpen,
473 theme,
474 themeHalves,
475 toggleMode,
476 ]);
477
478 useEffect(
479 () =>
480 onOpenCommandPalette((detail) => {
481 if (detail.open === "new-thread-in") {
482 openNewThreadIn();
483 } else if (detail.open === "add-project") {
484 openAddProject();
485 } else if (detail.query !== undefined) {
486 dispatch({
487 _tag: "OpenSearch",
488 query: detail.query,
489 ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}),
490 });
491 } else {
492 setOpen(true);
493 }
494 }),
495 [openAddProject, openNewThreadIn, setOpen],
496 );
497
498 return (
499 <ComposerHandleContext value={composerHandleRef}>
500 <CommandDialog
501 open={state.open}
502 onOpenChange={(open, eventDetails) => {
503 if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") {
504 eventDetails.cancel();
505 toggleMode("command");
506 return;
507 }
508 setOpen(open);
509 }}
510 >
511 {/* Block background focus calls for the entire time the palette is open. */}
512 <div className="contents" inert={state.open}>
513 {children}
514 </div>
515 <CommandPaletteDialog
516 mode={state.mode}
517 openIntent={state.openIntent}
518 setOpen={setOpen}
519 openOverlayMode={toggleMode}
520 clearOpenIntent={clearOpenIntent}
521 />
522 </CommandDialog>
523 </ComposerHandleContext>
524 );
525}
526
527function CommandPaletteDialog(props: {
528 readonly mode: SearchOverlayMode;
529 readonly openIntent: CommandPaletteOpenIntent | null;
530 readonly setOpen: (open: boolean) => void;
531 readonly openOverlayMode: (mode: SearchOverlayMode) => void;
532 readonly clearOpenIntent: () => void;
533}) {
534 const composerHandleRef = useComposerHandleContext();
535
536 return (
537 <CommandDialogPopup
538 aria-label={
539 props.mode === "files"
540 ? "File picker"
541 : props.mode === "content"
542 ? "Search project contents"
543 : "Command palette"
544 }
545 className={cn("overflow-hidden", props.mode === "content" && "h-105")}
546 data-command-palette="true"
547 data-palette-mode={props.mode}
548 data-testid="command-palette"
549 finalFocus={() => {
550 composerHandleRef?.current?.focusAtEnd();
551 return false;
552 }}
553 onBackdropPointerDown={() => {
554 props.setOpen(false);
555 }}
556 >
557 {props.mode === "files" ? (
558 <ProjectFilePicker setOpen={props.setOpen} />
559 ) : props.mode === "content" ? (
560 <ProjectContentSearchDialog onOpenChange={props.setOpen} />
561 ) : (
562 <OpenCommandPaletteDialog
563 openIntent={props.openIntent}
564 setOpen={props.setOpen}
565 openOverlayMode={props.openOverlayMode}
566 clearOpenIntent={props.clearOpenIntent}
567 />
568 )}
569 </CommandDialogPopup>
570 );
571}
572
573function OpenCommandPaletteDialog(props: {
574 readonly openIntent: CommandPaletteOpenIntent | null;
575 readonly setOpen: (open: boolean) => void;
576 readonly openOverlayMode: (mode: SearchOverlayMode) => void;
577 readonly clearOpenIntent: () => void;
578}) {
579 const navigate = useNavigate();
580 const pathname = useLocation({ select: (location) => location.pathname });
581 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
582 const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : "");
583 const [linkedThreadSearch, setLinkedThreadSearch] = useState(
584 openIntent?.kind === "search" ? openIntent : null,
585 );
586 const deferredQuery = useDeferredValue(query);
587 const isActionsOnly = deferredQuery.startsWith(">");
588 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
589 const resultListRef = useRef<LegendListRef | null>(null);
590 // Typing or entering a submenu clears the highlight. Base UI keeps its own on the
591 // first row, but the palette shows none until the user navigates, and the first
592 // ArrowDown lands on it.
593 const highlightClearedRef = useRef(false);
594 function clearTypedHighlight(): void {
595 highlightClearedRef.current = true;
596 setHighlightedItemValue(null);
597 }
598 const clientSettings = useClientSettings();
599 const createProject = useAtomCommand(projectEnvironment.create, {
600 reportFailure: false,
601 });
602 const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject();
603 const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {
604 reportFailure: false,
605 });
606 const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
607 reportFailure: false,
608 reportDefect: false,
609 });
610 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
611 reportFailure: false,
612 });
613 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
614 reportFailure: false,
615 });
616 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, {
617 reportFailure: false,
618 });
619 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
620 reportFailure: false,
621 });
622 const { environments } = useEnvironments();
623 const desktopLocalBootstraps = useDesktopLocalBootstraps();
624 const primaryEnvironmentId = usePrimaryEnvironmentId();
625 const newWorkWorkspace = useNewWorkWorkspace();
626 const availableSettingsSearchItems = useAvailableSettingsSearchItems();
627 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
628 useHandleNewThread();
629 const paletteRouteTarget = useParams({
630 strict: false,
631 select: (params) => resolveThreadRouteTarget(params),
632 });
633 const projects = useProjects();
634 const referenceThreadRef =
635 pathname === "/pull-requests"
636 ? environments.some(
637 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
638 )
639 ? PULL_REQUESTS_PANEL_REF
640 : null
641 : activeThread
642 ? scopeThreadRef(activeThread.environmentId, activeThread.id)
643 : null;
644 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
645 const activeThreadServerConfig = useServerConfigs().get(
646 activeThread?.environmentId ?? ("" as EnvironmentId),
647 );
648 const activeThreadReferenceCopyTarget =
649 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
650 ? null
651 : resolveThreadReferenceCopyTarget({
652 threadId: referenceThreadRef.threadId,
653 openPanelPullRequestUrl,
654 pullRequests: activeThread?.pullRequests,
655 linkedPullRequestUrl:
656 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
657 });
658 const copyActiveThreadReference = useCallback(async () => {
659 const target = activeThreadReferenceCopyTarget;
660 if (target === null) return;
661 try {
662 const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget);
663 if (!didCopy) return;
664 toastManager.add({
665 type: "success",
666 title: target.successTitle,
667 description: target.value,
668 });
669 } catch (error) {
670 console.error(error);
671 toastManager.add(
672 stackedThreadToast({
673 type: "error",
674 title: target.failureTitle,
675 description: error instanceof Error ? error.message : "An error occurred.",
676 }),
677 );
678 }
679 }, [activeThreadReferenceCopyTarget]);
680 const projectOrder = useUiStateStore((store) => store.projectOrder);
681 const threads = useThreadShells();
682 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
683 const {
684 theme,
685 themeHalves,
686 resolvedTheme,
687 appearanceMode,
688 setAppearanceMode,
689 setTheme,
690 setThemeHalf,
691 } = useTheme();
692 const customThemes = useCustomThemes();
693 const environmentThemes = useEnvironmentThemeDefinitions();
694 const themeCards = useMemo(() => {
695 const seen = new Set<string>();
696 return [
697 ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })),
698 ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes]
699 .filter((definition) => {
700 if (seen.has(definition.id)) return false;
701 seen.add(definition.id);
702 return true;
703 })
704 .map(getThemeCardDefinition),
705 ];
706 }, [customThemes, environmentThemes]);
707 const providers = useAtomValue(primaryServerProvidersAtom);
708 const providerEntryByEnvironmentAndInstanceId = useMemo(() => {
709 const map = new Map<string, ProviderInstanceEntry>();
710 for (const environment of environments) {
711 const serverConfig = environment.serverConfig;
712 const environmentProviders =
713 serverConfig?.providers ??
714 (environment.environmentId === primaryEnvironmentId ? providers : []);
715 const derived = deriveProviderInstanceEntries(environmentProviders);
716 // Settings fill the ACP registry identity (agent id, icon URL) the
717 // derived entries alone do not carry. This map is keyed by environment,
718 // not project, because it backs thread rows across every project on
719 // that environment; `enabled` stays machine-scoped (`null`) since no
720 // single project applies.
721 const entries = serverConfig
722 ? applyProviderInstanceSettings(derived, serverConfig.settings, null)
723 : derived;
724 for (const entry of entries) {
725 map.set(`${environment.environmentId}:${entry.instanceId}`, entry);
726 }
727 }
728 return map;
729 }, [environments, primaryEnvironmentId, providers]);
730 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
731 const currentView = viewStack.at(-1) ?? null;
732 const environmentIds = useMemo(
733 () =>
734 environments
735 .filter((environment) => environment.connection.phase === "connected")
736 .map((environment) => environment.environmentId),
737 [environments],
738 );
739 const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : "";
740 const threadSearch = useThreadSearch(environmentIds, threadSearchQuery);
741 const threadContentMatchByKey = useMemo(
742 () =>
743 new Map(
744 threadSearch.matches.flatMap((match) =>
745 match.source === "user" || match.source === "assistant"
746 ? [[threadSearchMatchKey(match), match] as const]
747 : [],
748 ),
749 ),
750 [threadSearch.matches],
751 );
752 const [browseGeneration, setBrowseGeneration] = useState(0);
753 const browseNavigationRef = useRef<ReturnType<typeof createBrowseNavigationCoordinator> | null>(
754 null,
755 );
756 if (browseNavigationRef.current === null) {
757 browseNavigationRef.current = createBrowseNavigationCoordinator();
758 }
759 const browseNavigation = browseNavigationRef.current;
760 const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState<EnvironmentId | null>(
761 null,
762 );
763 const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false);
764 const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);
765 // The name step of New project: while set, the palette input is the name.
766 const [newProjectFlow, setNewProjectFlow] = useState<{
767 readonly environmentId: EnvironmentId;
768 /** Machine of the Add project sources view under this step; null from the palette root. */
769 readonly sourcesEnvironmentId: EnvironmentId | null;
770 } | null>(null);
771 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
772 // State lags a render behind, so a repeated Enter could start a second create.
773 const newProjectSubmittingRef = useRef(false);
774 const cloneLookupGeneration = useRef(0);
775 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
776 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
777 const projectGroupingSettings = useMemo(
778 () => selectProjectGroupingSettings(clientSettings),
779 [clientSettings],
780 );
781
782 const environmentLabelById = useMemo(
783 () =>
784 new Map(
785 environments.map((environment) => [environment.environmentId, environment.label] as const),
786 ),
787 [environments],
788 );
789 const projectEnvironmentLocationById = useMemo(
790 () =>
791 new Map(
792 environments.map((environment) => {
793 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
794 const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target);
795 return [
796 environment.environmentId,
797 {
798 kind: isLocal ? "local" : "remote",
799 label: isPrimary
800 ? "Local"
801 : isLocal
802 ? `${environment.label} (Local)`
803 : environment.label,
804 machine: resolveEnvironmentMachineKind(environment.serverConfig),
805 },
806 ] as const;
807 }),
808 ),
809 [environments],
810 );
811 const orderedProjects = useMemo(
812 () =>
813 orderItemsByPreferredIds({
814 items: projects,
815 preferredIds: projectOrder,
816 getId: getProjectOrderKey,
817 getPreferenceIds: (project) => [
818 getProjectOrderKey(project),
819 legacyProjectCwdPreferenceKey(project.workspaceRoot),
820 ],
821 }),
822 [projectOrder, projects],
823 );
824 const unsortedProjectGroups = useMemo(
825 () =>
826 buildSidebarProjectSnapshots({
827 projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
828 settings: projectGroupingSettings,
829 primaryEnvironmentId,
830 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
831 }),
832 [
833 clientSettings.sidebarProjectSortOrder,
834 environmentLabelById,
835 orderedProjects,
836 primaryEnvironmentId,
837 projectGroupingSettings,
838 projects,
839 ],
840 );
841 const projectGroups = useMemo(
842 () =>
843 sortLogicalProjectsForSidebar(
844 unsortedProjectGroups,
845 threads,
846 clientSettings.sidebarProjectSortOrder,
847 ),
848 [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups],
849 );
850 const contextualProjectRef = useMemo(
851 () =>
852 resolveThreadActionProjectRef({
853 activeDraftThread,
854 activeThread: activeThread ?? undefined,
855 defaultProjectRef,
856 handleNewThread,
857 }),
858 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
859 );
860 const projectPickerEntries = useMemo(
861 () =>
862 buildSidebarProjectPickerEntries({
863 groups: projectGroups,
864 preferredProjectRef: contextualProjectRef,
865 }),
866 [contextualProjectRef, projectGroups],
867 );
868 const pickerProjects = useMemo(
869 () =>
870 projectPickerEntries.map(({ group, targetProject }) => ({
871 ...targetProject,
872 displayName: group.displayName,
873 })),
874 [projectPickerEntries],
875 );
876 const projectGroupByTargetKey = useMemo(
877 () =>
878 new Map(
879 projectPickerEntries.map(({ group, targetProject }) => [
880 `${targetProject.environmentId}:${targetProject.id}`,
881 group,
882 ]),
883 ),
884 [projectPickerEntries],
885 );
886
887 const addProjectEnvironmentOptions = useMemo(() => {
888 const options = environments
889 .filter((environment) => canCreateProjectInEnvironment(environment.connection.phase))
890 .map((environment): AddProjectEnvironmentOption => {
891 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
892 return {
893 environmentId: environment.environmentId,
894 label: resolveEnvironmentOptionLabel({
895 isPrimary,
896 environmentId: environment.environmentId,
897 runtimeLabel: environment.label,
898 }),
899 isPrimary,
900 machine: resolveEnvironmentMachineKind(environment.serverConfig),
901 isConnected: canCreateProjectInEnvironment(environment.connection.phase),
902 status: connectionStatusText(environment.connection),
903 };
904 });
905
906 options.sort((left, right) => {
907 if (left.isPrimary !== right.isPrimary) {
908 return left.isPrimary ? -1 : 1;
909 }
910 return left.label.localeCompare(right.label);
911 });
912
913 return options;
914 }, [environments]);
915 const defaultAddProjectEnvironmentId =
916 addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null;
917 const wslAddProjectEnvironmentOption = useMemo(
918 () =>
919 addProjectEnvironmentOptions.find((option) => {
920 if (!option.isConnected) {
921 return false;
922 }
923 const environment = environments.find(
924 (candidate) => candidate.environmentId === option.environmentId,
925 );
926 return environment
927 ? desktopLocalBackendId(environment.entry.target)?.startsWith("wsl:") === true
928 : false;
929 }) ?? null,
930 [addProjectEnvironmentOptions, environments],
931 );
932 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;
933 const canCreateProject = useEnvironmentScope(browseEnvironmentId, AuthOrchestrationOperateScope);
934 const browseEnvironment =
935 environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null;
936 // A desktop-local secondary backend (today: the WSL backend). The picker is
937 // available against these too — the desktop dispatches pickFolder into the
938 // backend's filesystem when routed by its instance id.
939 const browseEnvironmentIsDesktopLocal =
940 browseEnvironment !== null && isDesktopLocalConnectionTarget(browseEnvironment.entry.target);
941 // Map the browsed desktop-local env to its desktop pool instance id (e.g.
942 // "wsl:ubuntu"). The catalog environmentId is descriptor-derived and won't
943 // route on the desktop side; pickFolder only recognizes the pool id, which
944 // the bootstrap list exposes. Match on backend URL, exactly as Sidebar's
945 // LocalSecondaryStatus does (environment.displayUrl === bootstrap.httpBaseUrl).
946 const browseDesktopInstanceId = useMemo(() => {
947 if (!browseEnvironmentIsDesktopLocal || browseEnvironment === null) {
948 return null;
949 }
950 const displayUrl = browseEnvironment.displayUrl;
951 if (displayUrl === null) {
952 return null;
953 }
954 return (
955 desktopLocalBootstraps.find((bootstrap) => bootstrap.httpBaseUrl === displayUrl)?.id ?? null
956 );
957 }, [browseEnvironment, browseEnvironmentIsDesktopLocal, desktopLocalBootstraps]);
958 const sourceControlDiscovery = useEnvironmentQuery(
959 browseEnvironmentId === null
960 ? null
961 : sourceControlEnvironment.discovery({
962 environmentId: browseEnvironmentId,
963 input: {},
964 }),
965 );
966 const browseEnvironmentPlatform = getEnvironmentBrowsePlatform(
967 browseEnvironment?.serverConfig?.environment.platform.os,
968 );
969 const isRemoteProjectCloneFlow = addProjectCloneFlow !== null;
970 const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository";
971 // The destination step pins the repository folder onto the browsed path, so
972 // the proposed clone target is "<chosen folder>/<repo>" instead of the bare
973 // folder. A lookup reports "owner/repo"; a pasted clone URL falls back to its
974 // own last segment, minus ".git".
975 const pinnedCloneDirectoryName =
976 addProjectCloneFlow?.step === "confirm"
977 ? getCloneDirectoryName(
978 addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl,
979 )
980 : "";
981 const browsePath = useMemo(
982 () =>
983 getFilesystemBrowsePath(
984 query,
985 browseEnvironmentPlatform,
986 browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null,
987 ),
988 [
989 browseEnvironmentId,
990 browseEnvironmentPlatform,
991 isRemoteProjectRepositoryStep,
992 newProjectFlow,
993 query,
994 ],
995 );
996 const isBrowsing = browsePath.isBrowsing;
997 const browseDirectoryPath = browsePath.directoryPath;
998 const paletteMode = getCommandPaletteMode({ currentView, isBrowsing });
999 const getAddProjectInitialQueryForEnvironment = useCallback(
1000 (environmentId: EnvironmentId | null): string => {
1001 const environment = environments.find(
1002 (candidate) => candidate.environmentId === environmentId,
1003 );
1004 const environmentSettings = environment?.serverConfig?.settings ?? null;
1005 const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? "";
1006 if (baseDirectory.length === 0) {
1007 return "~/";
1008 }
1009 return ensureBrowseDirectoryPath(baseDirectory);
1010 },
1011 [environments],
1012 );
1013
1014 const projectCwdById = useMemo(
1015 () =>
1016 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),
1017 [projects],
1018 );
1019 const projectByKey = useMemo(
1020 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
1021 [projects],
1022 );
1023 const projectTitleById = useMemo(
1024 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),
1025 [projects],
1026 );
1027
1028 const activeThreadId = activeThread?.id;
1029 const supportsThreadFind =
1030 environments.find((environment) => environment.environmentId === activeThread?.environmentId)
1031 ?.serverConfig?.threadFind === true;
1032 const currentProjectEnvironmentId =
1033 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
1034 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
1035 // Where "without a project" threads start: the current environment when it
1036 // offers them, otherwise the first connected one that does.
1037 const scratchTargetEnvironmentId = scratchEnvironmentId(
1038 currentProjectEnvironmentId ?? primaryEnvironmentId,
1039 );
1040 const currentProjectCwd = currentProjectId
1041 ? (projectCwdById.get(currentProjectId) ?? null)
1042 : null;
1043 const currentProjectCwdForBrowse =
1044 browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId
1045 ? currentProjectCwd
1046 : null;
1047 const getBrowseCwdForEnvironment = useCallback(
1048 (environmentId: EnvironmentId | null): string | null =>
1049 environmentId && currentProjectEnvironmentId === environmentId ? currentProjectCwd : null,
1050 [currentProjectCwd, currentProjectEnvironmentId],
1051 );
1052 const relativePathNeedsActiveProject =
1053 isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null;
1054 const browseAccess = useFilesystemReadAccess(browseEnvironmentId);
1055 const hasBrowseTarget =
1056 isBrowsing &&
1057 browsePath.directoryPath.length > 0 &&
1058 browseEnvironmentId !== null &&
1059 !relativePathNeedsActiveProject;
1060 const browseQuery = useEnvironmentQuery(
1061 browseAccess.canReadFiles && hasBrowseTarget
1062 ? filesystemEnvironment.browse({
1063 environmentId: browseEnvironmentId,
1064 input: {
1065 partialPath: browsePath.directoryPath,
1066 ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}),
1067 },
1068 })
1069 : null,
1070 );
1071 const browseResult = browseQuery.data;
1072 const isBrowsePending = hasBrowseTarget && (browseAccess.isPending || browseQuery.isPending);
1073 const browseAccessError =
1074 hasBrowseTarget && !browseAccess.isPending && !browseAccess.canReadFiles
1075 ? (browseAccess.error ?? "This connection cannot browse host folders.")
1076 : null;
1077 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
1078 const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
1079 () =>
1080 pinnedCloneDirectoryName
1081 ? filterPinnedBrowseEntries({
1082 browseEntries,
1083 filterQuery: browsePath.filterQuery,
1084 pinnedDirectoryName: pinnedCloneDirectoryName,
1085 caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform),
1086 })
1087 : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery),
1088 [browseEntries, browseEnvironmentPlatform, browsePath.filterQuery, pinnedCloneDirectoryName],
1089 );
1090
1091 const prefetchBrowsePath = useCallback(
1092 async (
1093 partialPath: string,
1094 environmentId: EnvironmentId | null = browseEnvironmentId,
1095 cwd: string | null = currentProjectCwdForBrowse,
1096 ): Promise<void> => {
1097 if (!environmentId) {
1098 return;
1099 }
1100 const environment = environments.find(
1101 (candidate) => candidate.environmentId === environmentId,
1102 );
1103 if (
1104 !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ||
1105 !canPreloadBrowsePath(environment?.connection.phase)
1106 ) {
1107 return;
1108 }
1109
1110 await loadBrowsePath({
1111 environmentId,
1112 input: {
1113 partialPath,
1114 ...(cwd ? { cwd } : {}),
1115 },
1116 });
1117 },
1118 [browseEnvironmentId, currentProjectCwdForBrowse, environments, loadBrowsePath],
1119 );
1120
1121 useEffect(
1122 () => () => {
1123 browseNavigation.invalidate();
1124 },
1125 [browseNavigation],
1126 );
1127
1128 const openProjectFromSearch = useMemo(
1129 () => async (project: (typeof projects)[number]) => {
1130 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
1131 const groupedProjectKeys = group
1132 ? new Set(
1133 group.memberProjectRefs.map(
1134 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
1135 ),
1136 )
1137 : null;
1138 const latestThread = groupedProjectKeys
1139 ? (sortThreads(
1140 threads.filter(
1141 (thread) =>
1142 thread.archivedAt === null &&
1143 groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`),
1144 ),
1145 clientSettings.sidebarThreadSortOrder,
1146 )[0] ?? null)
1147 : getLatestThreadForProject(
1148 threads.filter((thread) => thread.environmentId === project.environmentId),
1149 project.id,
1150 clientSettings.sidebarThreadSortOrder,
1151 );
1152 if (latestThread) {
1153 await navigate({
1154 to: "/$environmentId/$threadId",
1155 params: buildThreadRouteParams(
1156 scopeThreadRef(latestThread.environmentId, latestThread.id),
1157 ),
1158 });
1159 return;
1160 }
1161
1162 await handleNewThread(scopeProjectRef(project.environmentId, project.id));
1163 },
1164 [
1165 clientSettings.sidebarThreadSortOrder,
1166 handleNewThread,
1167 navigate,
1168 projectGroupByTargetKey,
1169 threads,
1170 ],
1171 );
1172
1173 const projectSearchItems = useMemo(
1174 () =>
1175 buildProjectActionItems({
1176 projects: pickerProjects,
1177 valuePrefix: "project",
1178 searchTerms: (project) => {
1179 const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`)
1180 ?.memberProjects ?? [project];
1181 return buildCommandPaletteProjectMetadata({
1182 projects: members,
1183 locationByEnvironmentId: projectEnvironmentLocationById,
1184 }).searchTerms;
1185 },
1186 renderDescription: (project) => {
1187 const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`)
1188 ?.memberProjects ?? [project];
1189 const metadata = buildCommandPaletteProjectMetadata({
1190 projects: members,
1191 locationByEnvironmentId: projectEnvironmentLocationById,
1192 });
1193 const location = projectEnvironmentLocationById.get(project.environmentId) ?? {
1194 kind: "remote" as const,
1195 label: "Remote",
1196 machine: "server" as const,
1197 };
1198 return (
1199 <ProjectSearchDescription
1200 environmentLabels={metadata.environmentLabels}
1201 grouped={members.length > 1}
1202 location={location}
1203 workspaceRoot={project.workspaceRoot}
1204 />
1205 );
1206 },
1207 icon: projectFaviconIcon,
1208 runProject: openProjectFromSearch,
1209 }),
1210 [
1211 openProjectFromSearch,
1212 pickerProjects,
1213 projectEnvironmentLocationById,
1214 projectGroupByTargetKey,
1215 ],
1216 );
1217
1218 const projectThreadItems = useMemo(() => {
1219 const isScratch = (project: CommandPaletteProject) =>
1220 isScratchProject(project, scratchWorkspaceRootFor(project.environmentId));
1221 const projectItems = enumerateCommandPaletteItems(
1222 buildProjectActionItems({
1223 // The no-project home shows once, as the "No project" item below.
1224 projects: pickerProjects.filter((project) => !isScratch(project)),
1225 valuePrefix: "new-thread-in",
1226 searchTerms: (project) => {
1227 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
1228 const location = projectEnvironmentLocationById.get(project.environmentId);
1229 return [
1230 ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ??
1231 []),
1232 ...(location ? [location.label] : []),
1233 ];
1234 },
1235 renderDescription: (project) => {
1236 const location = projectEnvironmentLocationById.get(project.environmentId) ?? {
1237 kind: "remote",
1238 label: "Remote",
1239 machine: "server" as const,
1240 };
1241 return (
1242 <span className="flex min-w-0 items-center gap-1">
1243 <span className="inline-flex min-w-0 items-center gap-1">
1244 {location.kind === "remote" ? (
1245 <EnvironmentMachineIcon
1246 aria-hidden
1247 kind={location.machine}
1248 className={COMMAND_PALETTE_META_ICON_CLASS}
1249 />
1250 ) : null}
1251 <span className="truncate">{location.label}</span>
1252 </span>
1253 <CommandPaletteMetaDot />
1254 <span className="truncate">{project.workspaceRoot}</span>
1255 </span>
1256 );
1257 },
1258 icon: projectFaviconIcon,
1259 runProject: async (project) => {
1260 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
1261 const contextualRefBelongsToGroup =
1262 contextualProjectRef !== null &&
1263 group?.memberProjectRefs.some(
1264 (projectRef) =>
1265 projectRef.environmentId === contextualProjectRef.environmentId &&
1266 projectRef.projectId === contextualProjectRef.projectId,
1267 );
1268 await handleNewThread(
1269 contextualRefBelongsToGroup
1270 ? contextualProjectRef
1271 : scopeProjectRef(project.environmentId, project.id),
1272 );
1273 },
1274 }),
1275 );
1276 if (scratchTargetEnvironmentId === null) return projectItems;
1277
1278 // "No project" goes right after the current project: visible without
1279 // scrolling past every project, while Enter still starts in the current
1280 // one. When the current thread has no project, it is the current entry and
1281 // goes first. It keeps its own shortcut, so the projects' mod+1..9 hold.
1282 const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1;
1283 return [
1284 ...projectItems.slice(0, noProjectIndex),
1285 {
1286 kind: "action" as const,
1287 value: "new-thread-in:no-project",
1288 searchTerms: ["no project", "without project", "none"],
1289 title: "No project",
1290 icon: <MessageSquareDashedIcon className={ITEM_ICON_CLASS} />,
1291 shortcutCommand: "chat.newWithoutProject" as const,
1292 run: () => startScratchThread(scratchTargetEnvironmentId),
1293 },
1294 ...projectItems.slice(noProjectIndex),
1295 ];
1296 }, [
1297 contextualProjectRef,
1298 handleNewThread,
1299 pickerProjects,
1300 projectEnvironmentLocationById,
1301 projectGroupByTargetKey,
1302 scratchTargetEnvironmentId,
1303 scratchWorkspaceRootFor,
1304 startScratchThread,
1305 ]);
1306
1307 const allThreadItems = useMemo(
1308 () =>
1309 buildThreadActionItems({
1310 threads,
1311 ...(activeThreadId ? { activeThreadId } : {}),
1312 projectTitleById,
1313 sortOrder: clientSettings.sidebarThreadSortOrder,
1314 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
1315 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
1316 renderTrailingContent: (thread) => (
1317 <>
1318 <ThreadSearchPullRequestNumber
1319 thread={thread}
1320 query={threadSearchQuery}
1321 settled={thread.settledOverride === "settled"}
1322 />
1323 <ThreadRowTrailingStatus thread={thread} />
1324 </>
1325 ),
1326 renderDescription: (thread, { projectTitle }) => {
1327 const modelInstanceId =
1328 thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
1329 const providerEntry =
1330 providerEntryByEnvironmentAndInstanceId.get(
1331 `${thread.environmentId}:${modelInstanceId}`,
1332 ) ?? null;
1333 return (
1334 <ThreadCommandSubtitle
1335 project={projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null}
1336 projectTitle={projectTitle ?? null}
1337 environmentLabel={
1338 projectEnvironmentLocationById.get(thread.environmentId)?.label ?? "Remote"
1339 }
1340 branch={thread.branch}
1341 worktreePath={thread.worktreePath}
1342 isCurrent={thread.id === activeThreadId}
1343 driverKind={providerEntry?.driverKind ?? null}
1344 providerDisplayName={
1345 thread.runtime?.providerName ?? providerEntry?.displayName ?? modelInstanceId
1346 }
1347 acpRegistryAgentId={providerEntry?.acpRegistryAgentId}
1348 acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl}
1349 />
1350 );
1351 },
1352 getContentMatch: (thread) => {
1353 const match = threadContentMatchByKey.get(
1354 threadSearchMatchKey({
1355 environmentId: thread.environmentId,
1356 threadId: thread.id,
1357 }),
1358 );
1359 return match && (match.source === "user" || match.source === "assistant")
1360 ? {
1361 source: match.source,
1362 snippet: match.snippet,
1363 query: threadSearch.query,
1364 }
1365 : undefined;
1366 },
1367 runThread: async (thread) => {
1368 await navigate({
1369 to: "/$environmentId/$threadId",
1370 params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
1371 });
1372 },
1373 }),
1374 [
1375 activeThreadId,
1376 clientSettings.sidebarThreadSortOrder,
1377 navigate,
1378 projectCwdById,
1379 projectByKey,
1380 projectEnvironmentLocationById,
1381 projectTitleById,
1382 providerEntryByEnvironmentAndInstanceId,
1383 threadContentMatchByKey,
1384 threadSearch.query,
1385 threadSearchQuery,
1386 threads,
1387 ],
1388 );
1389 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
1390
1391 const pushPaletteView = useCallback(
1392 (view: CommandPaletteView): void => {
1393 browseNavigation.invalidate();
1394 setViewStack((previousViews) => [
1395 ...previousViews,
1396 {
1397 addonIcon: view.addonIcon,
1398 groups: view.groups,
1399 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
1400 },
1401 ]);
1402 highlightClearedRef.current = true;
1403 setHighlightedItemValue(null);
1404 setQuery(view.initialQuery ?? "");
1405 },
1406 [browseNavigation],
1407 );
1408
1409 function pushView(item: CommandPaletteSubmenuItem): void {
1410 pushPaletteView({
1411 addonIcon: item.addonIcon,
1412 groups: item.groups,
1413 ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),
1414 });
1415 }
1416
1417 function popView(): void {
1418 browseNavigation.invalidate();
1419 setAddProjectCloneFlow(null);
1420 setNewProjectFlow(null);
1421 if (viewStack.length <= 1) {
1422 setAddProjectEnvironmentId(null);
1423 } else if (newProjectFlow?.sourcesEnvironmentId) {
1424 // The machine switcher may have moved off the sources view's machine.
1425 setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId);
1426 }
1427 setViewStack((previousViews) => previousViews.slice(0, -1));
1428 setHighlightedItemValue(null);
1429 setQuery("");
1430 }
1431
1432 function handleQueryChange(nextQuery: string): void {
1433 browseNavigation.invalidate();
1434 clearTypedHighlight();
1435 setQuery(nextQuery);
1436 if (nextQuery === "" && currentView?.initialQuery) {
1437 popView();
1438 }
1439 }
1440
1441 const startAddProjectBrowse = useCallback(
1442 async (environmentId: EnvironmentId): Promise<void> => {
1443 const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId);
1444 const initialBrowsePath = getBrowseDirectoryPath(initialQuery);
1445 const browseCwd = getBrowseCwdForEnvironment(environmentId);
1446 const view: CommandPaletteView = {
1447 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1448 groups: [],
1449 initialQuery,
1450 };
1451
1452 await browseNavigation.run(
1453 () =>
1454 initialBrowsePath.length > 0
1455 ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd)
1456 : Promise.resolve(),
1457 () => {
1458 setAddProjectEnvironmentId(environmentId);
1459 setAddProjectCloneFlow(null);
1460 pushPaletteView(view);
1461 },
1462 );
1463 },
1464 [
1465 browseNavigation,
1466 getAddProjectInitialQueryForEnvironment,
1467 getBrowseCwdForEnvironment,
1468 prefetchBrowsePath,
1469 pushPaletteView,
1470 ],
1471 );
1472
1473 const startAddProjectClone = useCallback(
1474 (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => {
1475 setAddProjectEnvironmentId(environmentId);
1476 setAddProjectCloneFlow({ step: "repository", environmentId, source });
1477 pushPaletteView({
1478 addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS),
1479 groups: [],
1480 initialQuery: "",
1481 });
1482 },
1483 [pushPaletteView],
1484 );
1485
1486 /** Folder that holds an environment's name-only projects, or null when it has none. */
1487 const newProjectsRootFor = useCallback(
1488 (environmentId: EnvironmentId | null): string | null =>
1489 environments.find((environment) => environment.environmentId === environmentId)?.serverConfig
1490 ?.newProjectsRoot ?? null,
1491 [environments],
1492 );
1493
1494 const startNewProject = useCallback(
1495 (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => {
1496 setAddProjectEnvironmentId(environmentId);
1497 setAddProjectCloneFlow(null);
1498 setNewProjectFlow({ environmentId, sourcesEnvironmentId });
1499 pushPaletteView({
1500 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1501 groups: [],
1502 });
1503 },
1504 [pushPaletteView],
1505 );
1506
1507 const openSourceControlSettings = useCallback(() => {
1508 setOpen(false);
1509 void navigate({ to: "/settings/source-control" });
1510 }, [navigate, setOpen]);
1511
1512 const buildAddProjectSourceGroups = useCallback(
1513 (
1514 environmentId: EnvironmentId,
1515 readinessBySource: AddProjectRemoteSourceReadiness,
1516 ): CommandPaletteView["groups"] => {
1517 const sourceItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [
1518 {
1519 kind: "action",
1520 value: `action:add-project:${environmentId}:local`,
1521 searchTerms: ["local", "folder", "directory", "browse"],
1522 title: "Local folder",
1523 description: "Browse a folder on disk",
1524 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1525 keepOpen: true,
1526 run: async () => {
1527 await startAddProjectBrowse(environmentId);
1528 },
1529 },
1530 ];
1531
1532 if (newProjectsRootFor(environmentId) !== null) {
1533 sourceItems.unshift({
1534 kind: "action",
1535 value: `action:add-project:${environmentId}:new`,
1536 searchTerms: ["new project", "create", "empty", "folder"],
1537 title: "New project",
1538 description: "Create a project folder from a name",
1539 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1540 keepOpen: true,
1541 run: async () => {
1542 startNewProject(environmentId, environmentId);
1543 },
1544 });
1545 }
1546
1547 const orderedSources: ReadonlyArray<AddProjectRemoteSource> = [
1548 "url",
1549 ...sortAddProjectProviderSources(readinessBySource),
1550 ];
1551
1552 for (const source of orderedSources) {
1553 const label = addProjectRemoteSourceLabel(source);
1554 const title = source === "url" ? "Git URL" : `${label} repository`;
1555 const description =
1556 source === "url"
1557 ? "Clone from a remote URL"
1558 : `Clone ${label} ${addProjectRemoteSourcePathHint(source)}`;
1559 const readiness = readinessBySource(source);
1560 const disabledHint = readiness.hint;
1561
1562 const titleTrailingContent = readiness.ready ? undefined : (
1563 <span className="ml-auto">
1564 <Tooltip>
1565 <TooltipTrigger
1566 render={
1567 <Button
1568 variant="warning-outline"
1569 size="micro"
1570 onClick={() => {
1571 openSourceControlSettings();
1572 }}
1573 >
1574 Setup Required
1575 </Button>
1576 }
1577 />
1578 <TooltipPopup align="end" side="left">
1579 {disabledHint ?? "Open Settings -> Source Control to configure this provider."}
1580 </TooltipPopup>
1581 </Tooltip>
1582 </span>
1583 );
1584
1585 if (!readiness.ready) {
1586 sourceItems.push({
1587 kind: "action",
1588 value: `action:add-project:${environmentId}:${source}:not-ready`,
1589 searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"],
1590 title,
1591 description,
1592 disabled: true,
1593 icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS),
1594 ...(titleTrailingContent ? { titleTrailingContent } : {}),
1595 run: async () => {},
1596 });
1597 continue;
1598 }
1599
1600 sourceItems.push({
1601 kind: "action",
1602 value: `action:add-project:${environmentId}:${source}`,
1603 searchTerms: ["clone", "remote", "repository", "repo", "git", label],
1604 title,
1605 description,
1606 icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS),
1607 ...(titleTrailingContent ? { titleTrailingContent } : {}),
1608 keepOpen: true,
1609 run: async () => {
1610 startAddProjectClone(environmentId, source);
1611 },
1612 });
1613 }
1614
1615 return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }];
1616 },
1617 [
1618 newProjectsRootFor,
1619 openSourceControlSettings,
1620 startAddProjectBrowse,
1621 startAddProjectClone,
1622 startNewProject,
1623 ],
1624 );
1625
1626 const startAddProjectSourceSelection = useCallback(
1627 (environmentId: EnvironmentId): void => {
1628 const environment = environments.find(
1629 (candidate) => candidate.environmentId === environmentId,
1630 );
1631 if (!canCreateProjectInEnvironment(environment?.connection.phase)) {
1632 toastManager.add(
1633 stackedThreadToast({
1634 type: "error",
1635 title: "Environment unavailable",
1636 description: `${environment?.label ?? "The selected environment"} is not connected.`,
1637 }),
1638 );
1639 return;
1640 }
1641 setAddProjectEnvironmentId(environmentId);
1642 setAddProjectCloneFlow(null);
1643 pushPaletteView({
1644 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1645 groups: buildAddProjectSourceGroups(
1646 environmentId,
1647 buildPaletteRemoteSourceReadiness(
1648 browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null,
1649 ),
1650 ),
1651 });
1652 },
1653 [
1654 browseEnvironmentId,
1655 buildAddProjectSourceGroups,
1656 environments,
1657 pushPaletteView,
1658 sourceControlDiscovery.data,
1659 ],
1660 );
1661
1662 const buildEnvironmentItem = (
1663 option: AddProjectEnvironmentOption,
1664 value: string,
1665 run: (environmentId: EnvironmentId) => void,
1666 ): CommandPaletteActionItem => ({
1667 kind: "action",
1668 value,
1669 searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""],
1670 title: option.label,
1671 description: option.isConnected
1672 ? option.isPrimary
1673 ? "This device"
1674 : option.environmentId
1675 : option.status,
1676 disabled: !option.isConnected,
1677 icon: <EnvironmentMachineIcon kind={option.machine} className={ITEM_ICON_CLASS} />,
1678 keepOpen: true,
1679 run: async () => {
1680 run(option.environmentId);
1681 },
1682 });
1683 const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) =>
1684 buildEnvironmentItem(
1685 option,
1686 `action:add-project:environment:${option.environmentId}`,
1687 startAddProjectSourceSelection,
1688 ),
1689 );
1690 const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter(
1691 (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null,
1692 );
1693
1694 const addProjectEnvironmentGroups = useMemo<CommandPaletteView["groups"]>(
1695 () => [
1696 {
1697 value: "environments",
1698 label: "Environments",
1699 items: addProjectEnvironmentItems,
1700 },
1701 ],
1702 [addProjectEnvironmentItems],
1703 );
1704
1705 const openAddProjectFlow = useCallback(() => {
1706 // With no connected environment there is nothing to browse, so the only
1707 // useful next step is connecting one.
1708 if (addProjectEnvironmentOptions.length === 0) {
1709 setOpen(false);
1710 void navigate({ to: "/settings/connections" });
1711 return;
1712 }
1713
1714 if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {
1715 pushPaletteView({
1716 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1717 groups: addProjectEnvironmentGroups,
1718 });
1719 return;
1720 }
1721
1722 void startAddProjectSourceSelection(defaultAddProjectEnvironmentId);
1723 }, [
1724 addProjectEnvironmentGroups,
1725 addProjectEnvironmentOptions.length,
1726 defaultAddProjectEnvironmentId,
1727 navigate,
1728 pushPaletteView,
1729 setOpen,
1730 startAddProjectSourceSelection,
1731 ]);
1732
1733 // New project starts on this device (options list it first); the name step
1734 // lists the other machines when there is a choice.
1735 const openNewProjectFlow = () => {
1736 const firstOption = newProjectEnvironmentOptions[0];
1737 if (firstOption) startNewProject(firstOption.environmentId, null);
1738 };
1739
1740 useLayoutEffect(() => {
1741 if (openIntent?.kind !== "search") return;
1742 browseNavigation.invalidate();
1743 cloneLookupGeneration.current += 1;
1744 setIsRemoteProjectLookingUp(false);
1745 setAddProjectCloneFlow(null);
1746 setNewProjectFlow(null);
1747 setViewStack([]);
1748 setLinkedThreadSearch(openIntent);
1749 setQuery(openIntent.query);
1750 clearOpenIntent();
1751 }, [browseNavigation, clearOpenIntent, openIntent]);
1752
1753 useLayoutEffect(() => {
1754 if (openIntent?.kind !== "add-project") {
1755 return;
1756 }
1757 clearOpenIntent();
1758 openAddProjectFlow();
1759 }, [clearOpenIntent, openAddProjectFlow, openIntent]);
1760
1761 useLayoutEffect(() => {
1762 if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) {
1763 return;
1764 }
1765 clearOpenIntent();
1766 browseNavigation.invalidate();
1767 setAddProjectCloneFlow(null);
1768 setNewProjectFlow(null);
1769 setViewStack([]);
1770 setQuery("");
1771 // projectThreadItems already lists the current project first.
1772 pushPaletteView({
1773 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
1774 groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
1775 });
1776 }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]);
1777
1778 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
1779 if (newWorkWorkspace.project) {
1780 actionItems.push({
1781 kind: "action",
1782 value: "action:new-work-workspace",
1783 searchTerms: ["my work", "workspace", "new", "create"],
1784 title: newWorkWorkspace.label,
1785 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1786 disabled: newWorkWorkspace.disabled,
1787 run: newWorkWorkspace.run,
1788 });
1789 }
1790
1791 if (projects.length > 0) {
1792 const activeProjectTitle =
1793 projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ??
1794 (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null);
1795
1796 if (activeProjectTitle) {
1797 actionItems.push({
1798 kind: "action",
1799 value: "action:new-thread",
1800 searchTerms: ["new thread", "chat", "create", "draft"],
1801 title: (
1802 <>
1803 New thread in <span className="font-semibold">{activeProjectTitle}</span>
1804 </>
1805 ),
1806 icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
1807 shortcutCommand: "chat.newLocal",
1808 run: async () => {
1809 await startNewThreadFromContext({
1810 activeDraftThread,
1811 activeThread: activeThread ?? undefined,
1812 defaultProjectRef,
1813 handleNewThread,
1814 });
1815 },
1816 });
1817 }
1818
1819 actionItems.push({
1820 kind: "submenu",
1821 value: "action:new-thread-in",
1822 searchTerms: ["new thread", "project", "pick", "choose", "select"],
1823 title: "New thread in...",
1824 icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
1825 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
1826 groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
1827 });
1828 }
1829
1830 if (scratchTargetEnvironmentId !== null) {
1831 actionItems.push({
1832 kind: "action",
1833 value: "action:new-thread-without-project",
1834 searchTerms: ["new thread", "no project", "without project", "none", "chat"],
1835 title: "New thread without a project",
1836 icon: <MessageSquareDashedIcon className={ITEM_ICON_CLASS} />,
1837 shortcutCommand: "chat.newWithoutProject",
1838 run: () => startScratchThread(scratchTargetEnvironmentId),
1839 });
1840 }
1841
1842 if (activeThreadId && supportsThreadFind) {
1843 actionItems.push({
1844 kind: "action",
1845 value: "find-current-thread",
1846 title: "Find in current thread",
1847 searchTerms: ["find", "search", "messages", "plans"],
1848 icon: <TextSearchIcon className={ITEM_ICON_CLASS} />,
1849 shortcutCommand: "chat.find",
1850 run: async () => {
1851 requestThreadFindOpen();
1852 },
1853 });
1854 }
1855
1856 // Read once per open: the palette is a snapshot of the composer, not a live view of it.
1857 const promptLinkTarget = paletteRouteTarget
1858 ? paletteRouteTarget.kind === "server"
1859 ? paletteRouteTarget.threadRef
1860 : paletteRouteTarget.draftId
1861 : null;
1862 const promptLinkProjectRef = activeThread ?? activeDraftThread;
1863 const promptLinkInput =
1864 promptLinkTarget === null
1865 ? null
1866 : {
1867 target: promptLinkTarget,
1868 project: projects.find(
1869 (project) =>
1870 project.environmentId === promptLinkProjectRef?.environmentId &&
1871 project.id === promptLinkProjectRef.projectId,
1872 ),
1873 providers:
1874 environments.find(
1875 (environment) => environment.environmentId === promptLinkProjectRef?.environmentId,
1876 )?.serverConfig?.providers ?? [],
1877 threadModelSelection: activeThread?.modelSelection ?? null,
1878 };
1879 const plainPromptLink = promptLinkInput
1880 ? composerPromptLink({ ...promptLinkInput, withContext: false })
1881 : null;
1882 if (promptLinkInput !== null && plainPromptLink !== null) {
1883 actionItems.push({
1884 kind: "action",
1885 value: "action:copy-prompt-link",
1886 searchTerms: ["copy", "share", "prompt", "link"],
1887 title: "Copy prompt link",
1888 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1889 run: () => copyPromptLink(plainPromptLink),
1890 });
1891 if (promptLinkRepositoryOf(promptLinkInput.project) !== null) {
1892 const contextLink = composerPromptLink({ ...promptLinkInput, withContext: true });
1893 if (contextLink !== null) {
1894 actionItems.push({
1895 kind: "action",
1896 value: "action:copy-prompt-link-with-context",
1897 searchTerms: ["copy", "share", "prompt", "link", "repository", "model"],
1898 title: "Copy prompt link with repo and model",
1899 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1900 run: () => copyPromptLink(contextLink),
1901 });
1902 }
1903 }
1904 }
1905
1906 if (activeThreadReferenceCopyTarget !== null) {
1907 actionItems.push({
1908 kind: "action",
1909 value: "action:copy-thread-reference",
1910 searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"],
1911 title:
1912 activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID",
1913 description: activeThreadReferenceCopyTarget.value,
1914 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1915 shortcutCommand: "thread.copyReference",
1916 run: copyActiveThreadReference,
1917 });
1918 }
1919
1920 if (
1921 activeThread !== null &&
1922 threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported"
1923 ) {
1924 const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
1925 actionItems.push({
1926 kind: "action",
1927 value: "action:link-pull-request",
1928 searchTerms: ["link", "pull request", "pr", "attach", "stack"],
1929 title: "Link pull request to thread",
1930 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
1931 run: async () => {
1932 openLinkPullRequestDialog(threadRef);
1933 },
1934 });
1935 if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) {
1936 actionItems.push({
1937 kind: "action",
1938 value: "action:open-thread-pull-requests",
1939 searchTerms: ["pull requests", "linked", "stack", "prs"],
1940 title: "Show linked pull requests",
1941 disabled: visibleThreadPullRequests(activeThread.pullRequests).length === 0,
1942 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
1943 run: async () => {
1944 useRightPanelStore.getState().open(threadRef, "pull-requests");
1945 },
1946 });
1947 }
1948 }
1949
1950 if (activeThread !== null) {
1951 const thread = activeThread;
1952 actionItems.push({
1953 kind: "action",
1954 value: "action:restart-agent-session",
1955 searchTerms: ["restart", "reset", "reload", "agent", "session", "skills", "plugins", "mcp"],
1956 title: "Restart agent session",
1957 icon: <RotateCcwIcon className={ITEM_ICON_CLASS} />,
1958 // Stopping the provider process keeps the conversation: the next message
1959 // spawns a fresh one that resumes it and reloads skills, plugins, and MCP
1960 // servers. The fresh workspace scan updates the composer's slash menu.
1961 // Failures throw into executeItem's error toast.
1962 run: async () => {
1963 const { environmentId } = thread;
1964 if (thread.runtime !== null) {
1965 const stopped = await stopThreadSession({
1966 environmentId,
1967 input: { threadId: thread.id },
1968 });
1969 if (stopped._tag === "Failure") throw squashAtomCommandFailure(stopped);
1970 }
1971 // The server stops the process after accepting the command. A failed
1972 // stop shows in the thread.
1973 toastManager.add({
1974 type: "success",
1975 title: "Agent session will restart",
1976 description: "Your next message starts a fresh session.",
1977 });
1978 const project = projectByKey.get(`${environmentId}:${thread.projectId}`);
1979 if (!project) return;
1980 const refreshed = await refreshProviders({
1981 environmentId,
1982 input: {
1983 instanceId: thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId,
1984 cwd: thread.worktreePath ?? project.workspaceRoot,
1985 fresh: true,
1986 },
1987 });
1988 if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed);
1989 },
1990 });
1991 }
1992
1993 actionItems.push({
1994 kind: "action",
1995 value: "action:open-file-picker",
1996 searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"],
1997 title: "Go to file",
1998 icon: <FileSearchIcon className={ITEM_ICON_CLASS} />,
1999 keepOpen: true,
2000 shortcutCommand: "filePicker.toggle",
2001 run: async () => {
2002 openOverlayMode("files");
2003 },
2004 });
2005
2006 actionItems.push({
2007 kind: "action",
2008 value: "action:search-project-contents",
2009 searchTerms: ["search project", "find in files", "grep", "content search", "text search"],
2010 title: "Search project contents",
2011 icon: <TextSearchIcon className={ITEM_ICON_CLASS} />,
2012 keepOpen: true,
2013 shortcutCommand: "projectSearch.toggle",
2014 run: async () => {
2015 openOverlayMode("content");
2016 },
2017 });
2018
2019 if (newProjectEnvironmentOptions.length > 0) {
2020 actionItems.push({
2021 kind: "action",
2022 value: "action:new-project",
2023 searchTerms: ["new project", "create project", "empty", "folder"],
2024 title: "New project",
2025 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
2026 keepOpen: true,
2027 run: async () => {
2028 openNewProjectFlow();
2029 },
2030 });
2031 }
2032
2033 actionItems.push({
2034 kind: "action",
2035 value: "action:add-project",
2036 searchTerms: [
2037 "add project",
2038 "folder",
2039 "directory",
2040 "browse",
2041 "clone",
2042 "remote",
2043 "repository",
2044 "repo",
2045 "git",
2046 // Every host's name, spelled as a word: "azure devops" searches as `azure` and `devops`.
2047 ...sourceControlClients.definitions.flatMap((definition) =>
2048 definition.pickerLabel
2049 .toLowerCase()
2050 .split(/[^a-z0-9]+/u)
2051 .filter(Boolean),
2052 ),
2053 "url",
2054 "environment",
2055 ],
2056 title: "Add project",
2057 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
2058 keepOpen: true,
2059 run: async () => {
2060 openAddProjectFlow();
2061 },
2062 });
2063
2064 if (wslAddProjectEnvironmentOption) {
2065 actionItems.push({
2066 kind: "action",
2067 value: "action:add-project:wsl-folder",
2068 searchTerms: ["add project", "open", "wsl", "linux", "folder", "directory"],
2069 title: "Open WSL folder",
2070 description: wslAddProjectEnvironmentOption.label,
2071 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
2072 keepOpen: true,
2073 run: async () => {
2074 await startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId);
2075 },
2076 });
2077 }
2078
2079 const changeThemeItem: CommandPaletteSubmenuItem = {
2080 kind: "submenu",
2081 value: "action:change-theme",
2082 searchTerms: ["change theme", "appearance", "colors", "palette"],
2083 title: "Change theme",
2084 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
2085 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
2086 shortcutCommand: "theme.select",
2087 groups: [
2088 {
2089 value: "themes",
2090 label: "Change theme",
2091 items: themeCards.map(({ id, label, previews }) => ({
2092 kind: "action",
2093 value: id === null ? "theme:standard" : `theme:palette:${id}`,
2094 title: label,
2095 description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined,
2096 searchTerms: [label, "theme", "appearance"],
2097 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
2098 titleTrailingContent: (
2099 <span className="flex shrink-0 items-center gap-2">
2100 {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? (
2101 <span className="text-xs text-muted-foreground/70">Current</span>
2102 ) : null}
2103 <span className="flex items-center gap-1" aria-hidden>
2104 {previews.map((preview) => (
2105 <ThemePreviewCircle
2106 key={preview.mode}
2107 colors={preview.colors}
2108 mode={preview.mode}
2109 className="size-3 border-0"
2110 />
2111 ))}
2112 </span>
2113 </span>
2114 ),
2115 run: async () => {
2116 const saved =
2117 previews.length === 1 && id !== null
2118 ? setThemeHalf(previews[0]!.mode, id)
2119 : setTheme(id ?? appearanceMode);
2120 if (!saved) notifyThemeSaveFailure();
2121 },
2122 })),
2123 },
2124 ],
2125 };
2126 actionItems.push(changeThemeItem);
2127
2128 const changeAppearanceItem: CommandPaletteSubmenuItem = {
2129 kind: "submenu",
2130 value: "action:change-appearance",
2131 searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"],
2132 title: "Change appearance",
2133 icon: <MonitorIcon className={ITEM_ICON_CLASS} />,
2134 addonIcon: <MonitorIcon className={ADDON_ICON_CLASS} />,
2135 shortcutCommand: "appearance.cycle",
2136 groups: [
2137 {
2138 value: "appearance",
2139 label: "Change appearance",
2140 items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({
2141 kind: "action",
2142 value: `appearance:${mode}`,
2143 title: label,
2144 searchTerms: [label, "appearance", "mode"],
2145 icon: <Icon className={ITEM_ICON_CLASS} />,
2146 titleTrailingContent:
2147 appearanceMode === mode ? (
2148 <span className="text-xs text-muted-foreground/70">Current</span>
2149 ) : undefined,
2150 run: async () => {
2151 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
2152 },
2153 })),
2154 },
2155 ],
2156 };
2157 actionItems.push(changeAppearanceItem);
2158
2159 useLayoutEffect(() => {
2160 if (openIntent?.kind !== "change-theme") return;
2161 clearOpenIntent();
2162 browseNavigation.invalidate();
2163 cloneLookupGeneration.current += 1;
2164 setIsRemoteProjectLookingUp(false);
2165 setAddProjectCloneFlow(null);
2166 setNewProjectFlow(null);
2167 setViewStack([]);
2168 pushPaletteView({
2169 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
2170 groups: [{ value: "themes", label: "Change theme", items: [] }],
2171 });
2172 }, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]);
2173
2174 actionItems.push({
2175 kind: "action",
2176 value: "action:theme-editor",
2177 searchTerms: ["theme", "appearance", "colors", "palette", "customize"],
2178 title: "Toggle theme editor",
2179 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
2180 shortcutCommand: "themeEditor.toggle",
2181 run: async () => {
2182 toggleThemeEditorForTheme({
2183 theme,
2184 themeHalves,
2185 initialAppearance: resolvedTheme,
2186 });
2187 },
2188 });
2189
2190 if (
2191 environments.some(
2192 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
2193 )
2194 ) {
2195 actionItems.push({
2196 kind: "action",
2197 value: "action:pull-requests",
2198 searchTerms: ["pull requests", "prs", "pr", "github", "review", "merge", "branch"],
2199 title: "Open pull requests",
2200 icon: <PullRequestGlyph.pullRequest className={ITEM_ICON_CLASS} />,
2201 run: async () => {
2202 await navigate({ to: "/pull-requests", search: readPullRequestListPreferences() });
2203 },
2204 });
2205 }
2206
2207 actionItems.push({
2208 kind: "action",
2209 value: "action:usage",
2210 searchTerms: ["usage", "use", "tokens", "cost", "spend", "limits", "stats", "analytics"],
2211 title: "Open usage",
2212 icon: <ChartNoAxesColumnIcon className={ITEM_ICON_CLASS} />,
2213 shortcutCommand: "usage.open",
2214 run: async () => {
2215 await navigate({ to: "/usage" });
2216 },
2217 });
2218
2219 actionItems.push({
2220 kind: "action",
2221 value: "action:settings",
2222 searchTerms: ["settings", "preferences", "configuration", "keybindings"],
2223 title: "Open settings",
2224 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
2225 run: async () => {
2226 await navigate({ to: "/settings" });
2227 },
2228 });
2229
2230 // Target the active thread or draft's project, falling back to the first sidebar group.
2231 const contextualProjectGroup =
2232 (contextualProjectRef
2233 ? projectGroupByTargetKey.get(
2234 `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`,
2235 )
2236 : null) ??
2237 projectGroups[0] ??
2238 null;
2239 if (contextualProjectGroup) {
2240 actionItems.push({
2241 kind: "action",
2242 value: "action:project-settings",
2243 searchTerms: [
2244 "project",
2245 "settings",
2246 "name",
2247 "icon",
2248 "scripts",
2249 "model",
2250 "workspace",
2251 "grouping",
2252 "checkout",
2253 "remove",
2254 "t3.json",
2255 ],
2256 title: "Project settings",
2257 description: contextualProjectGroup.displayName,
2258 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
2259 run: async () => {
2260 await navigate({
2261 to: "/projects/$projectKey",
2262 params: { projectKey: contextualProjectGroup.projectKey },
2263 });
2264 },
2265 });
2266 }
2267
2268 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
2269 const settingsSearchItems: CommandPaletteActionItem[] = searchSettings(
2270 deferredQuery,
2271 availableSettingsSearchItems,
2272 ).map((item) => ({
2273 kind: "action",
2274 value: `setting:${item.id}`,
2275 searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])],
2276 title: item.title,
2277 description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`,
2278 ...(item.secondary ? { secondary: true } : {}),
2279 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
2280 run: async () => {
2281 await navigate({
2282 to: item.to,
2283 hash: item.targetId ?? item.id,
2284 replace: pathname === item.to,
2285 hashScrollIntoView: false,
2286 });
2287 },
2288 }));
2289 const sourceSelectionViewValue =
2290 addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`;
2291 const activeGroups =
2292 addProjectEnvironmentId !== null &&
2293 currentView !== null &&
2294 currentView.groups[0]?.value === sourceSelectionViewValue
2295 ? buildAddProjectSourceGroups(
2296 addProjectEnvironmentId,
2297 buildPaletteRemoteSourceReadiness(sourceControlDiscovery.data),
2298 )
2299 : currentView?.groups[0]?.value === "themes"
2300 ? changeThemeItem.groups
2301 : currentView?.groups[0]?.value === "appearance"
2302 ? changeAppearanceItem.groups
2303 : (currentView?.groups ?? rootGroups);
2304
2305 const filteredGroups = filterCommandPaletteGroups({
2306 activeGroups,
2307 query: deferredQuery,
2308 isInSubmenu: currentView !== null,
2309 projectSearchItems: projectSearchItems,
2310 settingsSearchItems,
2311 threadSearchItems:
2312 linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query
2313 ? buildLinkedThreadActionItems({
2314 ...linkedThreadSearch.linkedThreads,
2315 query: linkedThreadSearch.query,
2316 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
2317 runThread: async (thread) => {
2318 await navigate({
2319 to: "/$environmentId/$threadId",
2320 params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
2321 });
2322 },
2323 })
2324 : allThreadItems,
2325 });
2326
2327 const handleAddProjectForEnvironment = useCallback(
2328 async (input: {
2329 readonly environmentId: EnvironmentId;
2330 readonly rawCwd: string;
2331 readonly platform: string;
2332 readonly currentProjectCwd: string | null;
2333 }) => {
2334 const environment = environments.find(
2335 (candidate) => candidate.environmentId === input.environmentId,
2336 );
2337 if (!canCreateProjectInEnvironment(environment?.connection.phase)) {
2338 toastManager.add(
2339 stackedThreadToast({
2340 type: "error",
2341 title: "Environment unavailable",
2342 description: `${environment?.label ?? "The selected environment"} is not connected.`,
2343 }),
2344 );
2345 return;
2346 }
2347 const rawCwd = input.rawCwd;
2348
2349 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) {
2350 toastManager.add(
2351 stackedThreadToast({
2352 type: "error",
2353 title: "Failed to add project",
2354 description: "Windows-style paths are only supported on Windows.",
2355 }),
2356 );
2357 return;
2358 }
2359
2360 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !input.currentProjectCwd) {
2361 toastManager.add(
2362 stackedThreadToast({
2363 type: "error",
2364 title: "Failed to add project",
2365 description: "Relative paths require an active project.",
2366 }),
2367 );
2368 return;
2369 }
2370
2371 const cwd = resolveProjectPathForDispatch(rawCwd, input.currentProjectCwd);
2372 if (cwd.length === 0) return;
2373
2374 const existing = findProjectByPath(
2375 projects.filter((project) => project.environmentId === input.environmentId),
2376 cwd,
2377 );
2378 if (existing) {
2379 const latestThread = getLatestThreadForProject(
2380 threads.filter((thread) => thread.environmentId === existing.environmentId),
2381 existing.id,
2382 clientSettings.sidebarThreadSortOrder,
2383 );
2384 if (latestThread && latestThread.settledOverride !== "settled") {
2385 await navigate({
2386 to: "/$environmentId/$threadId",
2387 params: buildThreadRouteParams(
2388 scopeThreadRef(latestThread.environmentId, latestThread.id),
2389 ),
2390 });
2391 } else {
2392 const navigationResult = await settlePromise(() =>
2393 handleNewThread(scopeProjectRef(existing.environmentId, existing.id)),
2394 );
2395 if (navigationResult._tag === "Failure") {
2396 const error = squashAtomCommandFailure(navigationResult);
2397 toastManager.add(
2398 stackedThreadToast({
2399 type: "error",
2400 title: "Failed to open project",
2401 description: error instanceof Error ? error.message : "An error occurred.",
2402 }),
2403 );
2404 return;
2405 }
2406 }
2407 setOpen(false);
2408 return;
2409 }
2410
2411 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) {
2412 toastManager.add(
2413 stackedThreadToast({
2414 type: "error",
2415 title: "Cannot add project",
2416 description: "This connection cannot add projects.",
2417 }),
2418 );
2419 return;
2420 }
2421 const projectId = newProjectId();
2422 const createResult = await createProject({
2423 environmentId: input.environmentId,
2424 input: {
2425 projectId,
2426 title: inferProjectTitleFromPath(cwd),
2427 workspaceRoot: cwd,
2428 createWorkspaceRootIfMissing: true,
2429 defaultModelSelection: null,
2430 },
2431 });
2432 if (createResult._tag === "Failure") {
2433 if (!isAtomCommandInterrupted(createResult)) {
2434 const error = squashAtomCommandFailure(createResult);
2435 toastManager.add(
2436 stackedThreadToast({
2437 type: "error",
2438 title: "Failed to add project",
2439 description: error instanceof Error ? error.message : "An error occurred.",
2440 }),
2441 );
2442 }
2443 return;
2444 }
2445
2446 const navigationResult = await settlePromise(() =>
2447 handleNewThread(scopeProjectRef(input.environmentId, projectId)),
2448 );
2449 if (navigationResult._tag === "Failure") {
2450 const error = squashAtomCommandFailure(navigationResult);
2451 toastManager.add(
2452 stackedThreadToast({
2453 type: "error",
2454 title: "Failed to add project",
2455 description: error instanceof Error ? error.message : "An error occurred.",
2456 }),
2457 );
2458 return;
2459 }
2460 setOpen(false);
2461 },
2462 [
2463 handleNewThread,
2464 createProject,
2465 environments,
2466 navigate,
2467 primaryEnvironmentId,
2468 projects,
2469 providers,
2470 setOpen,
2471 clientSettings.sidebarThreadSortOrder,
2472 threads,
2473 ],
2474 );
2475
2476 const handleAddProject = useCallback(
2477 async (rawCwd: string) => {
2478 if (!browseEnvironmentId) return;
2479 await handleAddProjectForEnvironment({
2480 environmentId: browseEnvironmentId,
2481 rawCwd,
2482 platform: browseEnvironmentPlatform,
2483 currentProjectCwd: currentProjectCwdForBrowse,
2484 });
2485 },
2486 [
2487 browseEnvironmentId,
2488 browseEnvironmentPlatform,
2489 currentProjectCwdForBrowse,
2490 handleAddProjectForEnvironment,
2491 ],
2492 );
2493
2494 const newProjectName = query.trim();
2495 const canSubmitNewProject =
2496 newProjectFlow !== null &&
2497 newProjectName.length > 0 &&
2498 !isCreatingNewProject &&
2499 canCreateProjectInEnvironment(browseEnvironment?.connection.phase);
2500
2501 async function submitNewProject(): Promise<void> {
2502 if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) {
2503 return;
2504 }
2505 newProjectSubmittingRef.current = true;
2506 setIsCreatingNewProject(true);
2507 try {
2508 const root = newProjectsRootFor(newProjectFlow.environmentId);
2509 if (root === null) return;
2510 await handleAddProjectForEnvironment({
2511 environmentId: newProjectFlow.environmentId,
2512 rawCwd: getNewProjectPathPreview(root, newProjectName),
2513 platform: browseEnvironmentPlatform,
2514 currentProjectCwd: null,
2515 });
2516 } finally {
2517 newProjectSubmittingRef.current = false;
2518 setIsCreatingNewProject(false);
2519 }
2520 }
2521
2522 function getDefaultCloneParentPath(environmentId: EnvironmentId): string {
2523 return getAddProjectInitialQueryForEnvironment(environmentId);
2524 }
2525
2526 async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise<void> {
2527 if (!addProjectCloneFlow) {
2528 return;
2529 }
2530 if (!canCreateProjectInEnvironment(browseEnvironment?.connection.phase)) {
2531 toastManager.add(
2532 stackedThreadToast({
2533 type: "error",
2534 title: "Environment unavailable",
2535 description: `${browseEnvironment?.label ?? "The selected environment"} is not connected.`,
2536 }),
2537 );
2538 return;
2539 }
2540
2541 if (addProjectCloneFlow.step === "repository") {
2542 const rawRepository = query.trim();
2543 if (rawRepository.length === 0 || isRemoteProjectLookingUp) {
2544 return;
2545 }
2546
2547 const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source);
2548 if (!provider) {
2549 const destinationPath = getCloneDestinationPath(
2550 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
2551 getCloneDirectoryName(rawRepository),
2552 );
2553 setAddProjectCloneFlow({
2554 step: "confirm",
2555 environmentId: addProjectCloneFlow.environmentId,
2556 source: addProjectCloneFlow.source,
2557 repositoryInput: rawRepository,
2558 repository: null,
2559 remoteUrl: normalizePastedCloneUrl(rawRepository),
2560 });
2561 setHighlightedItemValue(null);
2562 setQuery(destinationPath);
2563 setBrowseGeneration((generation) => generation + 1);
2564 return;
2565 }
2566
2567 setIsRemoteProjectLookingUp(true);
2568 const lookupGeneration = ++cloneLookupGeneration.current;
2569 const lookupResult = await lookupRepository({
2570 environmentId: addProjectCloneFlow.environmentId,
2571 input: {
2572 provider,
2573 repository: rawRepository,
2574 },
2575 });
2576 if (lookupGeneration !== cloneLookupGeneration.current) return;
2577 setIsRemoteProjectLookingUp(false);
2578 if (lookupResult._tag === "Failure") {
2579 if (!isAtomCommandInterrupted(lookupResult)) {
2580 toastManager.add(
2581 stackedThreadToast({
2582 type: "error",
2583 title: "Repository lookup failed",
2584 description: errorMessage(squashAtomCommandFailure(lookupResult)),
2585 }),
2586 );
2587 }
2588 return;
2589 }
2590 const repository = lookupResult.value;
2591 const destinationPath = getCloneDestinationPath(
2592 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
2593 getCloneDirectoryName(repository.nameWithOwner),
2594 );
2595 setAddProjectCloneFlow({
2596 step: "confirm",
2597 environmentId: addProjectCloneFlow.environmentId,
2598 source: addProjectCloneFlow.source,
2599 repositoryInput: rawRepository,
2600 repository,
2601 remoteUrl: getDefaultCloneUrl(repository),
2602 });
2603 setHighlightedItemValue(null);
2604 setQuery(destinationPath);
2605 setBrowseGeneration((generation) => generation + 1);
2606 return;
2607 }
2608
2609 const rawDestination = (destinationPathInput ?? query).trim();
2610 if (
2611 !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthSourceControlWriteScope) ||
2612 !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthOrchestrationOperateScope)
2613 ) {
2614 toastManager.add(
2615 stackedThreadToast({
2616 type: "error",
2617 title: "Clone unavailable",
2618 description: "This connection needs permission to write source control and add projects.",
2619 }),
2620 );
2621 return;
2622 }
2623 if (rawDestination.length === 0 || isRemoteProjectCloning) return;
2624
2625 if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) {
2626 toastManager.add(
2627 stackedThreadToast({
2628 type: "error",
2629 title: "Clone failed",
2630 description: "Windows-style paths are only supported on Windows.",
2631 }),
2632 );
2633 return;
2634 }
2635
2636 if (isExplicitRelativeProjectPath(rawDestination) && !currentProjectCwdForBrowse) {
2637 toastManager.add(
2638 stackedThreadToast({
2639 type: "error",
2640 title: "Clone failed",
2641 description: "Relative paths require an active project.",
2642 }),
2643 );
2644 return;
2645 }
2646
2647 const destinationPath = resolveProjectPathForDispatch(
2648 rawDestination,
2649 currentProjectCwdForBrowse,
2650 );
2651 if (destinationPath.length === 0) {
2652 return;
2653 }
2654
2655 // Older servers only offer the blocking clone: the palette has to wait
2656 // for git so it can add the project afterwards.
2657 if (browseEnvironment?.serverConfig?.environment.capabilities.projectCloneTracking !== true) {
2658 setIsRemoteProjectCloning(true);
2659 const cloneResult = await cloneRepository({
2660 environmentId: addProjectCloneFlow.environmentId,
2661 input: {
2662 remoteUrl: addProjectCloneFlow.remoteUrl,
2663 destinationPath,
2664 },
2665 });
2666 setIsRemoteProjectCloning(false);
2667 if (cloneResult._tag === "Failure") {
2668 if (!isAtomCommandInterrupted(cloneResult)) {
2669 toastManager.add(
2670 stackedThreadToast({
2671 type: "error",
2672 title: "Clone failed",
2673 description: errorMessage(squashAtomCommandFailure(cloneResult)),
2674 }),
2675 );
2676 }
2677 return;
2678 }
2679 await handleAddProject(cloneResult.value.cwd);
2680 return;
2681 }
2682
2683 // The server creates the project and clones in the background; progress
2684 // shows in a toast and in the draft's composer banner, so the palette
2685 // closes as soon as the clone is under way. Only problems found before
2686 // git runs (bad destination, unknown repository) come back here.
2687 const projectId = newProjectId();
2688 setIsRemoteProjectCloning(true);
2689 const startResult = await startProjectClone({
2690 environmentId: addProjectCloneFlow.environmentId,
2691 input: {
2692 projectId,
2693 title: inferProjectTitleFromPath(destinationPath),
2694 createdAt: new Date().toISOString(),
2695 remoteUrl: addProjectCloneFlow.remoteUrl,
2696 destinationPath,
2697 },
2698 });
2699 setIsRemoteProjectCloning(false);
2700 if (startResult._tag === "Failure") {
2701 if (!isAtomCommandInterrupted(startResult)) {
2702 toastManager.add(
2703 stackedThreadToast({
2704 type: "error",
2705 title: "Clone failed",
2706 description: errorMessage(squashAtomCommandFailure(startResult)),
2707 }),
2708 );
2709 }
2710 return;
2711 }
2712 setOpen(false);
2713 const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId);
2714 // The create event usually lands before this call returns; give the shell
2715 // stream a moment so the draft opens with its project resolved instead of
2716 // flashing the project picker.
2717 await waitForProject(projectRef, 3_000).catch(() => null);
2718 const navigationResult = await settlePromise(() => handleNewThread(projectRef));
2719 if (navigationResult._tag === "Failure") {
2720 const error = squashAtomCommandFailure(navigationResult);
2721 toastManager.add(
2722 stackedThreadToast({
2723 type: "error",
2724 title: "Failed to open project",
2725 description: error instanceof Error ? error.message : "An error occurred.",
2726 }),
2727 );
2728 }
2729 }
2730
2731 const browseTo = useCallback(
2732 async (name: string): Promise<void> => {
2733 const nextQuery = pinnedCloneDirectoryName
2734 ? getCloneDestinationBrowsePath({
2735 browseDirectoryPath: browsePath.directoryPath,
2736 selectedDirectoryName: name,
2737 cloneDirectoryName: pinnedCloneDirectoryName,
2738 caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform),
2739 })
2740 : appendBrowsePathSegment(query, name);
2741 await browseNavigation.run(
2742 () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)),
2743 () => {
2744 setHighlightedItemValue(null);
2745 setQuery(nextQuery);
2746 setBrowseGeneration((generation) => generation + 1);
2747 },
2748 );
2749 },
2750 [
2751 browseNavigation,
2752 browseEnvironmentPlatform,
2753 browsePath.directoryPath,
2754 pinnedCloneDirectoryName,
2755 prefetchBrowsePath,
2756 query,
2757 ],
2758 );
2759
2760 const browseUp = useCallback(async (): Promise<void> => {
2761 const parentPath = browsePath.parentPath;
2762 if (parentPath === null) {
2763 return;
2764 }
2765
2766 const nextQuery = getCloneDestinationPath(parentPath, pinnedCloneDirectoryName);
2767 await browseNavigation.run(
2768 () => prefetchBrowsePath(parentPath),
2769 () => {
2770 setHighlightedItemValue(null);
2771 setQuery(nextQuery);
2772 setBrowseGeneration((generation) => generation + 1);
2773 },
2774 );
2775 }, [browseNavigation, browsePath.parentPath, pinnedCloneDirectoryName, prefetchBrowsePath]);
2776
2777 // Resolve the add-project path from browse data when available. When the
2778 // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the
2779 // directory itself. Otherwise the user typed a partial leaf name, so we need
2780 // the exact browse entry's fullPath or fall back to the raw query.
2781 const resolvedAddProjectPath = hasTrailingPathSeparator(query)
2782 ? (browseResult?.parentPath ?? query.trim())
2783 : (exactBrowseEntry?.fullPath ?? query.trim());
2784
2785 const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp;
2786
2787 const browseGroups = buildBrowseGroups({
2788 browseEntries: visibleBrowseEntries,
2789 browseQuery: query,
2790 canBrowseUp,
2791 upIcon: <CornerLeftUpIcon className={ITEM_ICON_CLASS} />,
2792 directoryIcon: <FolderIcon className={ITEM_ICON_CLASS} />,
2793 browseUp,
2794 browseTo,
2795 });
2796 const cloneDestinationBrowseGroups = useMemo(
2797 () =>
2798 browseGroups.map((group) =>
2799 group.value === "directories" ? { ...group, label: "Select where to clone" } : group,
2800 ),
2801 [browseGroups],
2802 );
2803
2804 const remoteProjectContext = useMemo(() => {
2805 if (addProjectCloneFlow?.step !== "confirm") {
2806 return null;
2807 }
2808
2809 return {
2810 title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput,
2811 description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl,
2812 icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS),
2813 };
2814 }, [addProjectCloneFlow]);
2815
2816 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
2817 const newProjectPathPreview =
2818 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
2819 // The name step's way out to folders and clones, for the selected machine.
2820 // It replaces the name step (and a sources view for another machine under
2821 // it), so Back returns to wherever New project was opened from.
2822 const showExistingProjectSources = (environmentId: EnvironmentId) => {
2823 const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null;
2824 if (sourcesEnvironmentId === environmentId) {
2825 popView();
2826 return;
2827 }
2828 if (sourcesEnvironmentId !== null) {
2829 setViewStack((previousViews) => previousViews.slice(0, -1));
2830 }
2831 popView();
2832 startAddProjectSourceSelection(environmentId);
2833 };
2834 const switchNewProjectEnvironment = (environmentId: EnvironmentId) => {
2835 setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId }));
2836 setAddProjectEnvironmentId(environmentId);
2837 };
2838 const selectedNewProjectEnvironment =
2839 newProjectFlow === null
2840 ? undefined
2841 : newProjectEnvironmentOptions.find(
2842 (option) => option.environmentId === newProjectFlow.environmentId,
2843 );
2844 // Shown when there is a choice, or when the selected machine went away and
2845 // another one can take over.
2846 const showNewProjectMachines =
2847 newProjectFlow !== null &&
2848 (newProjectEnvironmentOptions.length > 1 ||
2849 (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0));
2850 const newProjectEnvironmentLabel = showNewProjectMachines
2851 ? (selectedNewProjectEnvironment?.label ??
2852 environmentLabelById.get(newProjectFlow.environmentId) ??
2853 null)
2854 : null;
2855 const newProjectMachineGroup: CommandPaletteView["groups"][number] | null =
2856 !showNewProjectMachines || newProjectFlow === null
2857 ? null
2858 : {
2859 value: "new-project-machines",
2860 label: "Environments",
2861 items: newProjectEnvironmentOptions.map((option) => ({
2862 ...buildEnvironmentItem(
2863 option,
2864 `new-project:environment:${option.environmentId}`,
2865 switchNewProjectEnvironment,
2866 ),
2867 // The create in flight keeps the machine it started on.
2868 ...(isCreatingNewProject ? { disabled: true } : {}),
2869 ...(option.environmentId === newProjectFlow.environmentId
2870 ? {
2871 titleTrailingContent: (
2872 <CheckIcon className="ms-auto size-4 shrink-0 text-muted-foreground/70" />
2873 ),
2874 }
2875 : {}),
2876 })),
2877 };
2878 const newProjectExistingGroup: CommandPaletteView["groups"][number] | null =
2879 newProjectFlow === null
2880 ? null
2881 : {
2882 value: "new-project-existing",
2883 label: "",
2884 items: [
2885 {
2886 kind: "action",
2887 value: "new-project:existing",
2888 searchTerms: [],
2889 title: "Add existing project",
2890 description: "Open a folder or clone a repository",
2891 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
2892 titleTrailingContent: (
2893 <ChevronRightIcon className="-me-0.5 ms-auto size-4 shrink-0 text-muted-foreground/70" />
2894 ),
2895 keepOpen: true,
2896 run: async () => {
2897 showExistingProjectSources(newProjectFlow.environmentId);
2898 },
2899 },
2900 ],
2901 };
2902
2903 let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
2904 if (newProjectFlow !== null) {
2905 displayedGroups = [
2906 ...(newProjectMachineGroup ? [newProjectMachineGroup] : []),
2907 ...(newProjectExistingGroup ? [newProjectExistingGroup] : []),
2908 ];
2909 } else if (addProjectCloneFlow?.step === "repository") {
2910 displayedGroups = [];
2911 } else if (addProjectCloneFlow?.step === "confirm") {
2912 displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups;
2913 } else if (isBrowsing) {
2914 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
2915 }
2916 const resultRows = buildCommandPaletteRows(displayedGroups);
2917 const autoHighlightsFirstRow =
2918 !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null;
2919
2920 const inputPlaceholder =
2921 newProjectFlow !== null
2922 ? "Project name"
2923 : (remoteProjectInputPlaceholder(addProjectCloneFlow) ??
2924 getCommandPaletteInputPlaceholder(paletteMode));
2925 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
2926 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
2927 const canWriteSourceControl = useEnvironmentScope(
2928 addProjectCloneFlow?.environmentId ?? null,
2929 AuthSourceControlWriteScope,
2930 );
2931 const canCreateClonedProject = useEnvironmentScope(
2932 addProjectCloneFlow?.environmentId ?? null,
2933 AuthOrchestrationOperateScope,
2934 );
2935 const canCloneProject = canWriteSourceControl && canCreateClonedProject;
2936 const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm";
2937 const canSubmitBrowsePath =
2938 isBrowsing &&
2939 !relativePathNeedsActiveProject &&
2940 canCreateProject &&
2941 (!isCloneDestinationStep || canCloneProject) &&
2942 canCreateProjectInEnvironment(browseEnvironment?.connection.phase);
2943 const willCreateProjectPath =
2944 canSubmitBrowsePath &&
2945 !isBrowsePending &&
2946 browseAccessError === null &&
2947 query.trim().length > 0 &&
2948 !hasHighlightedBrowseItem &&
2949 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
2950 const useMetaForMod = isMacPlatform(navigator.platform);
2951 const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl";
2952 const submitActionLabel = isCloneDestinationStep
2953 ? willCreateProjectPath
2954 ? "Create & Clone"
2955 : "Clone"
2956 : willCreateProjectPath
2957 ? "Create & Add"
2958 : "Add";
2959 const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter";
2960 const hasHighlightedNewProjectItem = newProjectFlow !== null && highlightedItemValue !== null;
2961 const createShortcutLabel = hasHighlightedNewProjectItem
2962 ? `${submitModifierLabel} Enter`
2963 : "Enter";
2964 const remoteProjectButtonLabel = addProjectCloneFlow
2965 ? addProjectCloneFlow.source === "url"
2966 ? "Continue"
2967 : "Lookup"
2968 : null;
2969 const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning;
2970 const canSubmitRemoteProjectFlow =
2971 addProjectCloneFlow?.step === "repository" &&
2972 query.trim().length > 0 &&
2973 canCreateProjectInEnvironment(browseEnvironment?.connection.phase) &&
2974 !isRemoteProjectPending;
2975 const fileManagerName = getLocalFileManagerName(navigator.platform);
2976 const canOpenProjectFromFileManager =
2977 isBrowsing &&
2978 browseEnvironmentId !== null &&
2979 // For a desktop-local (WSL) env, only offer the picker once we have resolved
2980 // its desktop pool instance id. Without it pickFolder can't be routed to the
2981 // WSL filesystem and would open the primary (Windows) picker, then add the
2982 // chosen Windows path against the WSL env -- a wrong-path footgun. Stay
2983 // hidden until the bootstrap mapping is available rather than mis-routing.
2984 (browseEnvironmentId === primaryEnvironmentId ||
2985 (browseEnvironmentIsDesktopLocal && browseDesktopInstanceId !== null)) &&
2986 typeof window !== "undefined" &&
2987 window.desktopBridge !== undefined;
2988 const fileManagerInitialPath = useMemo(() => {
2989 if (!canOpenProjectFromFileManager) {
2990 return undefined;
2991 }
2992
2993 const trimmedQuery = query.trim();
2994 if (trimmedQuery.length === 0) {
2995 return undefined;
2996 }
2997
2998 const initialPath = hasTrailingPathSeparator(query)
2999 ? (browseResult?.parentPath ?? trimmedQuery)
3000 : browseDirectoryPath || trimmedQuery;
3001
3002 const resolvedPath = resolveProjectPathForDispatch(initialPath, currentProjectCwdForBrowse);
3003 return resolvedPath.length > 0 ? resolvedPath : undefined;
3004 }, [
3005 browseDirectoryPath,
3006 browseResult?.parentPath,
3007 canOpenProjectFromFileManager,
3008 currentProjectCwdForBrowse,
3009 query,
3010 ]);
3011
3012 function isPrimaryModifierPressed(event: KeyboardEvent<HTMLInputElement>): boolean {
3013 return useMetaForMod ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
3014 }
3015
3016 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
3017 const command = resolveShortcutCommand(event, keybindings, {
3018 platform: navigator.platform,
3019 context: { modelPickerOpen: false },
3020 });
3021 if (threadJumpIndexFromCommand(command ?? "") !== null) {
3022 event.preventDefault();
3023 event.stopPropagation();
3024 const matchingItem = displayedGroups
3025 .flatMap((group) => group.items)
3026 .find((item) => item.shortcutCommand === command);
3027 if (matchingItem) {
3028 executeItem(matchingItem);
3029 }
3030 return;
3031 }
3032 if (command === "thread.copyReference") {
3033 event.preventDefault();
3034 event.stopPropagation();
3035 if (activeThreadReferenceCopyTarget === null) return;
3036 setOpen(false);
3037 void copyActiveThreadReference();
3038 return;
3039 }
3040 // ChatView ignores shortcuts while the palette is open, so handle find here
3041 // instead of letting the browser's own Find open.
3042 if (command === "chat.find" && activeThreadId && !event.nativeEvent.isComposing) {
3043 event.preventDefault();
3044 event.stopPropagation();
3045 setOpen(false);
3046 requestThreadFindOpen();
3047 return;
3048 }
3049
3050 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
3051 event.preventDefault();
3052 void submitAddProjectCloneFlow();
3053 return;
3054 }
3055
3056 // Enter creates the project unless an item below the name is
3057 // highlighted, in which case it runs that item. Mod+Enter always creates.
3058 if (
3059 newProjectFlow !== null &&
3060 event.key === "Enter" &&
3061 (!hasHighlightedNewProjectItem || isPrimaryModifierPressed(event)) &&
3062 // Enter that confirms an IME composition is part of typing the name.
3063 !event.nativeEvent.isComposing &&
3064 event.keyCode !== 229
3065 ) {
3066 // Base UI would otherwise also click the highlighted row.
3067 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3068 event.preventDefault();
3069 void submitNewProject();
3070 return;
3071 }
3072
3073 const shouldSubmitBrowsePath =
3074 canSubmitBrowsePath &&
3075 event.key === "Enter" &&
3076 (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event));
3077
3078 if (shouldSubmitBrowsePath) {
3079 event.preventDefault();
3080 if (isCloneDestinationStep) {
3081 void submitAddProjectCloneFlow(resolvedAddProjectPath);
3082 } else {
3083 void handleAddProject(resolvedAddProjectPath);
3084 }
3085 return;
3086 }
3087
3088 if (event.key === "Backspace" && query === "" && isSubmenu) {
3089 event.preventDefault();
3090 popView();
3091 return;
3092 }
3093
3094 // Base UI ignores navigation keys with modifiers, so these fallbacks do too.
3095 if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return;
3096 // Base UI only keeps a hidden highlight on the first row when it auto-highlights.
3097 const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined;
3098 if (
3099 event.key === "ArrowDown" &&
3100 highlightClearedRef.current &&
3101 firstItemValue &&
3102 !event.nativeEvent.isComposing
3103 ) {
3104 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3105 event.preventDefault();
3106 highlightClearedRef.current = false;
3107 setHighlightedItemValue(firstItemValue);
3108 scrollCommandPaletteRowIntoView(
3109 resultListRef.current,
3110 resultRows.rowIndexByItemIndex[0] ?? 0,
3111 );
3112 return;
3113 }
3114
3115 // Base UI clicks the highlighted row on Enter, which does nothing once the
3116 // virtualized list has unmounted it, so run the tracked highlight directly.
3117 if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
3118 const highlightedItem = findHighlightedCommandPaletteItem(
3119 displayedGroups,
3120 highlightedItemValue ?? firstItemValue ?? null,
3121 );
3122 if (highlightedItem) {
3123 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3124 event.preventDefault();
3125 event.stopPropagation();
3126 executeItem(highlightedItem);
3127 }
3128 }
3129 }
3130
3131 function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void {
3132 if (item.disabled) {
3133 return;
3134 }
3135
3136 if (item.kind === "submenu") {
3137 pushView(item);
3138 return;
3139 }
3140
3141 if (!item.keepOpen) {
3142 setOpen(false);
3143 }
3144
3145 void item.run().catch((error: unknown) => {
3146 toastManager.add(
3147 stackedThreadToast({
3148 type: "error",
3149 title: "Unable to run command",
3150 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3151 }),
3152 );
3153 });
3154 }
3155
3156 const handleOpenProjectFromFileManager = useCallback(async () => {
3157 if (!canOpenProjectFromFileManager || isPickingProjectFolder) {
3158 return;
3159 }
3160 const api = readLocalApi();
3161 if (!api) {
3162 return;
3163 }
3164
3165 setIsPickingProjectFolder(true);
3166 let pickedPath: string | null = null;
3167 let desktopWslState: DesktopWslState | null = null;
3168 try {
3169 desktopWslState =
3170 browseEnvironmentId === primaryEnvironmentId && browseEnvironmentPlatform === "Linux"
3171 ? ((await window.desktopBridge?.getWslState().catch(() => null)) ?? null)
3172 : null;
3173 // Route the picker to the browsed env's backend filesystem. The desktop
3174 // only resolves a "wsl:*" pool instance id, so for a desktop-local env we
3175 // pass the bootstrap-mapped instance id (not the catalog environmentId).
3176 // A WSL-only primary has no secondary bootstrap, so resolve its instance
3177 // id from desktop settings. Windows and combo-mode primaries still omit
3178 // the target to preserve the native primary picker. The desktop converts
3179 // a WSL UNC selection back to a Linux path before returning.
3180 const pickerTargetEnvironmentId = resolveProjectPickerTarget({
3181 browseEnvironmentId,
3182 primaryEnvironmentId,
3183 desktopInstanceId: browseDesktopInstanceId,
3184 wslConfiguration: desktopWslState,
3185 });
3186 const pickerOptions = {
3187 ...(fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : {}),
3188 ...(pickerTargetEnvironmentId ? { targetEnvironmentId: pickerTargetEnvironmentId } : {}),
3189 };
3190 pickedPath = await api.dialogs.pickFolder(
3191 Object.keys(pickerOptions).length > 0 ? pickerOptions : undefined,
3192 );
3193 } catch {
3194 // Ignore picker failures and leave the palette open.
3195 setIsPickingProjectFolder(false);
3196 return;
3197 }
3198 setIsPickingProjectFolder(false);
3199 if (!pickedPath) {
3200 return;
3201 }
3202 if (parseWslUncPath(pickedPath)) {
3203 desktopWslState ??= (await window.desktopBridge?.getWslState().catch(() => null)) ?? null;
3204 let primaryRunningDistro: string | null = null;
3205 try {
3206 primaryRunningDistro =
3207 window.desktopBridge
3208 ?.getLocalEnvironmentBootstraps()
3209 .find((bootstrap) => bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID)?.runningDistro ??
3210 null;
3211 } catch {
3212 // Keep UNC routing strict when the live primary identity cannot be read.
3213 }
3214 const selection = resolveWslProjectSelection(
3215 pickedPath,
3216 applyWslEnvironmentConfiguration(
3217 environments.flatMap((environment) => {
3218 const backendId = desktopLocalBackendId(environment.entry.target);
3219 if (!backendId) {
3220 return [];
3221 }
3222
3223 const bootstrap = desktopLocalBootstraps.find(
3224 (candidate) => candidate.httpBaseUrl === environment.displayUrl,
3225 );
3226 const runningDistro = bootstrap?.runningDistro ?? null;
3227 return [{ environmentId: environment.environmentId, backendId, runningDistro }];
3228 }),
3229 primaryEnvironmentId,
3230 desktopWslState ?? null,
3231 primaryRunningDistro,
3232 ),
3233 );
3234 if (!selection) {
3235 toastManager.add(
3236 stackedThreadToast({
3237 type: "error",
3238 title: "Could not add WSL project",
3239 description: "Start the matching WSL backend, then choose the folder again.",
3240 }),
3241 );
3242 return;
3243 }
3244 await handleAddProjectForEnvironment({
3245 environmentId: selection.environmentId,
3246 rawCwd: selection.linuxPath,
3247 platform: "Linux",
3248 currentProjectCwd: null,
3249 });
3250 return;
3251 }
3252 await handleAddProject(pickedPath);
3253 }, [
3254 browseDesktopInstanceId,
3255 browseEnvironmentId,
3256 browseEnvironmentPlatform,
3257 canOpenProjectFromFileManager,
3258 desktopLocalBootstraps,
3259 environments,
3260 fileManagerInitialPath,
3261 handleAddProject,
3262 handleAddProjectForEnvironment,
3263 isPickingProjectFolder,
3264 primaryEnvironmentId,
3265 ]);
3266
3267 const inputAccessory =
3268 newProjectFlow !== null ? (
3269 <Tooltip>
3270 <TooltipTrigger
3271 render={
3272 <Button
3273 variant="outline"
3274 size="xs"
3275 tabIndex={-1}
3276 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"
3277 aria-label={`Create (${createShortcutLabel})`}
3278 disabled={!canSubmitNewProject}
3279 onMouseDown={(event) => {
3280 event.preventDefault();
3281 }}
3282 onClick={() => {
3283 void submitNewProject();
3284 }}
3285 />
3286 }
3287 >
3288 <span>{isCreatingNewProject ? "Creating" : "Create"}</span>
3289 <KbdGroup className="pointer-events-none -me-0.5">
3290 <Kbd>{createShortcutLabel}</Kbd>
3291 </KbdGroup>
3292 </TooltipTrigger>
3293 <TooltipPopup side="top">{`Create (${createShortcutLabel})`}</TooltipPopup>
3294 </Tooltip>
3295 ) : addProjectCloneFlow?.step === "repository" ? (
3296 <Tooltip>
3297 <TooltipTrigger
3298 render={
3299 <Button
3300 variant="outline"
3301 size="xs"
3302 tabIndex={-1}
3303 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"
3304 aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
3305 disabled={!canSubmitRemoteProjectFlow}
3306 onMouseDown={(event) => {
3307 event.preventDefault();
3308 }}
3309 onClick={() => {
3310 void submitAddProjectCloneFlow();
3311 }}
3312 />
3313 }
3314 >
3315 <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
3316 <KbdGroup className="pointer-events-none -me-0.5">
3317 <Kbd>Enter</Kbd>
3318 </KbdGroup>
3319 </TooltipTrigger>
3320 <TooltipPopup side="top">{remoteProjectButtonLabel ?? "Continue"} (Enter)</TooltipPopup>
3321 </Tooltip>
3322 ) : isBrowsing ? (
3323 <Tooltip>
3324 <TooltipTrigger
3325 render={
3326 <Button
3327 variant="outline"
3328 size="xs"
3329 tabIndex={-1}
3330 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"
3331 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
3332 disabled={
3333 !canCreateProject ||
3334 !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
3335 relativePathNeedsActiveProject ||
3336 (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending))
3337 }
3338 onMouseDown={(event) => {
3339 event.preventDefault();
3340 }}
3341 onClick={() => {
3342 if (relativePathNeedsActiveProject) {
3343 return;
3344 }
3345 if (isCloneDestinationStep) {
3346 void submitAddProjectCloneFlow(resolvedAddProjectPath);
3347 } else {
3348 void handleAddProject(resolvedAddProjectPath);
3349 }
3350 }}
3351 />
3352 }
3353 >
3354 <span>
3355 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
3356 </span>
3357 <KbdGroup className="pointer-events-none -me-0.5">
3358 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
3359 </KbdGroup>
3360 </TooltipTrigger>
3361 <TooltipPopup side="top">
3362 {isCloneDestinationStep && !canCloneProject
3363 ? "This connection needs permission to write source control and add projects."
3364 : canCreateProject
3365 ? `${submitActionLabel} (${addShortcutLabel})`
3366 : "This connection cannot add projects."}
3367 </TooltipPopup>
3368 </Tooltip>
3369 ) : null;
3370
3371 const footerActionLabel =
3372 newProjectFlow !== null
3373 ? highlightedItemValue === null
3374 ? "Create"
3375 : "Select"
3376 : addProjectCloneFlow?.step === "repository"
3377 ? (remoteProjectButtonLabel ?? "Continue")
3378 : !canSubmitBrowsePath || hasHighlightedBrowseItem
3379 ? "Select"
3380 : undefined;
3381
3382 const footerTrailing = canOpenProjectFromFileManager ? (
3383 <CommandFooterAction
3384 disabled={isPickingProjectFolder}
3385 onClick={() => {
3386 void handleOpenProjectFromFileManager();
3387 }}
3388 >
3389 {`Open in ${fileManagerName}`}
3390 </CommandFooterAction>
3391 ) : null;
3392
3393 return (
3394 <CommandPaletteContent
3395 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
3396 aria-label="Command palette"
3397 autoHighlight={autoHighlightsFirstRow ? "always" : false}
3398 footerActionLabel={footerActionLabel}
3399 footerTrailing={footerTrailing}
3400 inputAccessory={inputAccessory}
3401 inputProps={{
3402 // The submit button is absolutely positioned over the field, so the
3403 // inner input must reserve enough room for the full action label.
3404 className: hasHighlightedNewProjectItem
3405 ? "*:data-[slot=autocomplete-input]:pe-38!"
3406 : addProjectCloneFlow?.step === "repository" || newProjectFlow !== null
3407 ? "*:data-[slot=autocomplete-input]:pe-32!"
3408 : isBrowsing
3409 ? browseInputEndPaddingClass({
3410 willCreateProjectPath,
3411 hasHighlightedBrowseItem,
3412 })
3413 : undefined,
3414 placeholder: inputPlaceholder,
3415 ...(isSubmenu
3416 ? {
3417 startAddon: (
3418 <button
3419 type="button"
3420 className="flex cursor-pointer items-center"
3421 aria-label="Back"
3422 onClick={popView}
3423 >
3424 <ArrowLeftIcon />
3425 </button>
3426 ),
3427 }
3428 : isBrowsing
3429 ? { startAddon: <FolderPlusIcon /> }
3430 : {}),
3431 onKeyDown: handleKeyDown,
3432 }}
3433 mode="none"
3434 items={resultRows.itemValues}
3435 virtualized
3436 onItemHighlighted={(value, eventDetails) => {
3437 if (eventDetails.reason === "none" && highlightClearedRef.current) return;
3438 highlightClearedRef.current = false;
3439 setHighlightedItemValue(typeof value === "string" ? value : null);
3440 const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index];
3441 if (eventDetails.reason === "keyboard" && rowIndex !== undefined) {
3442 scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex);
3443 }
3444 }}
3445 onValueChange={handleQueryChange}
3446 showBackHint={isSubmenu}
3447 value={query}
3448 >
3449 {newProjectPathPreview !== null ? (
3450 <div className="p-2 pb-0">
3451 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
3452 <FolderPlusIcon className={ITEM_ICON_CLASS} />
3453 <span className="flex min-w-0 flex-1 flex-col">
3454 <span className="truncate text-foreground text-sm">
3455 {newProjectName.length > 0 ? newProjectName : "New project"}
3456 </span>
3457 <span className="truncate text-muted-foreground/85 text-xs">
3458 {newProjectName.length > 0
3459 ? `Creates ${newProjectPathPreview}`
3460 : `Goes in ${newProjectsRoot}`}
3461 {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`}
3462 </span>
3463 </span>
3464 </div>
3465 </div>
3466 ) : null}
3467 {remoteProjectContext ? (
3468 <div className="p-2 pb-0">
3469 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div>
3470 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
3471 {remoteProjectContext.icon}
3472 <span className="flex min-w-0 flex-1 flex-col">
3473 <span className="truncate text-foreground text-sm">{remoteProjectContext.title}</span>
3474 <span className="truncate text-muted-foreground/85 text-xs">
3475 {remoteProjectContext.description}
3476 </span>
3477 </span>
3478 </div>
3479 </div>
3480 ) : null}
3481 {browseAccessError ? (
3482 <div role="alert" className="px-4 py-3 text-sm text-muted-foreground">
3483 {browseAccessError}
3484 </div>
3485 ) : isBrowsePending && browseResult === null ? (
3486 <div role="status" className="px-4 py-3 text-sm text-muted-foreground">
3487 Loading folders...
3488 </div>
3489 ) : null}
3490 <CommandPaletteVirtualizedResults
3491 rows={resultRows.rows}
3492 listRef={resultListRef}
3493 highlightedItemValue={highlightedItemValue}
3494 isActionsOnly={isActionsOnly}
3495 keybindings={keybindings}
3496 onExecuteItem={executeItem}
3497 {...(addProjectCloneFlow?.step === "repository"
3498 ? {
3499 emptyStateMessage:
3500 addProjectCloneFlow.source === "url"
3501 ? "Enter a Git clone URL and press Enter to continue."
3502 : "Enter a repository path and press Enter to look it up.",
3503 }
3504 : addProjectCloneFlow?.step === "confirm"
3505 ? { emptyStateMessage: "Choose a destination path and press Enter to clone." }
3506 : relativePathNeedsActiveProject
3507 ? { emptyStateMessage: "Relative paths require an active project." }
3508 : willCreateProjectPath
3509 ? {
3510 emptyStateMessage: "Press Enter to create this folder and add it as a project.",
3511 }
3512 : threadSearch.isPending
3513 ? { emptyStateMessage: "Searching thread messages…" }
3514 : {})}
3515 />
3516 </CommandPaletteContent>
3517 );
3518}
3519
3520function ProjectSearchDescription(props: {
3521 readonly environmentLabels: ReadonlyArray<string>;
3522 readonly grouped: boolean;
3523 readonly location: {
3524 readonly kind: "local" | "remote";
3525 readonly label: string;
3526 readonly machine: EnvironmentMachineKind;
3527 };
3528 readonly workspaceRoot: string;
3529}) {
3530 if (!props.grouped) {
3531 return (
3532 <span className="flex min-w-0 items-center gap-1">
3533 <span className="inline-flex min-w-0 items-center gap-1">
3534 {props.location.kind === "remote" ? (
3535 <EnvironmentMachineIcon
3536 aria-hidden
3537 kind={props.location.machine}
3538 className={COMMAND_PALETTE_META_ICON_CLASS}
3539 />
3540 ) : null}
3541 <span className="truncate">{props.location.label}</span>
3542 </span>
3543 <CommandPaletteMetaDot />
3544 <span className="truncate">{props.workspaceRoot}</span>
3545 </span>
3546 );
3547 }
3548
3549 return <span className="truncate">{props.environmentLabels.join(" · ")}</span>;
3550}