Add a keyboard-driven project setup flow (#2097)

* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
This commit is contained in:
Mohamed Boudra
2026-07-15 21:16:10 +02:00
committed by GitHub
parent 90e0a0e353
commit dfe3330ef8
52 changed files with 3851 additions and 1023 deletions
+26 -6
View File
@@ -1,5 +1,9 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectAddResponse, WorkspaceGithubCloneProtocol } from "@getpaseo/protocol/messages";
import type {
ProjectAddResponse,
WorkspaceGithubCloneProtocol,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
normalizeWorkspaceDescriptor,
@@ -62,6 +66,21 @@ interface WorkspaceOpenCallbacks {
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks {
repo: string;
targetDirectory: string;
@@ -95,11 +114,12 @@ export async function openProjectDirectly(
};
}
input.addEmptyProject(
normalizedServerId,
normalizeProjectWithoutWorkspacesDescriptor(payload.project),
);
input.setHasHydratedWorkspaces(normalizedServerId, true);
registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return { ok: true };
}
+16 -14
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
@@ -59,7 +59,7 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
titleKey:
| "shell.commandCenter.openProject"
| "shell.commandCenter.addProject"
| "shell.commandCenter.home"
| "sidebar.actions.settings";
icon?: "plus" | "settings" | "home";
@@ -71,7 +71,7 @@ interface CommandCenterActionDefinition {
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
{
id: "new-agent",
titleKey: "shell.commandCenter.openProject",
titleKey: "shell.commandCenter.addProject",
icon: "plus",
actionId: "new-agent",
keywords: ["open", "project", "folder", "workspace", "repo"],
@@ -146,6 +146,9 @@ export function useCommandCenter() {
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const addProjectRequest = useAddProjectFlowStore((state) => state.request);
const openAddProjectFlow = useAddProjectFlowStore((state) => state.open);
const closeAddProjectFlow = useAddProjectFlowStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -318,23 +321,21 @@ export function useCommandCenter() {
[setOpen],
);
const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
void openProjectPicker();
openAddProjectFlow();
return;
}
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
},
[openProjectPicker, setOpen],
[openAddProjectFlow, setOpen],
);
const handleSelectItem = useCallback(
@@ -373,6 +374,7 @@ export function useCommandCenter() {
prevOpenRef.current = open;
if (!open) {
closeAddProjectFlow();
setQuery("");
setActiveIndex(0);
@@ -403,7 +405,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [open]);
}, [closeAddProjectFlow, open]);
useEffect(() => {
if (!open) return;
@@ -414,7 +416,7 @@ export function useCommandCenter() {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
if (!open || addProjectRequest) return false;
const currentItems = itemsRef.current;
if (key === "Escape") {
@@ -443,11 +445,11 @@ export function useCommandCenter() {
return false;
},
[open],
[addProjectRequest, open],
);
useEffect(() => {
if (!open || !isWeb) return;
if (!open || addProjectRequest || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
@@ -466,7 +468,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open, handleKeyEvent]);
}, [addProjectRequest, open, handleKeyEvent]);
return {
open,
@@ -18,7 +18,7 @@ import {
type ShortcutCallbackName,
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
@@ -54,7 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const openProjectPickerAction = useOpenProjectPicker();
const openProjectPickerAction = useOpenAddProject();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
@@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
export function useOpenAddProject(): (preferredHostId?: string) => void {
const openFlow = useAddProjectFlowStore((state) => state.open);
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
return useCallback(
(preferredHostId?: string) => {
openFlow(preferredHostId);
setCommandCenterOpen(true);
},
[openFlow, setCommandCenterOpen],
);
}
@@ -1,15 +0,0 @@
import { useCallback } from "react";
import { useHostChooser } from "@/hosts/host-chooser";
import { useProjectPickerStore } from "@/stores/project-picker-store";
export function useOpenProjectPicker(): () => void {
const chooseHost = useHostChooser();
const openProjectPicker = useProjectPickerStore((state) => state.open);
return useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: openProjectPicker,
});
}, [chooseHost, openProjectPicker]);
}