Add a global New workspace picker (#1324)

* Add global New workspace project picker

* Clean up New workspace picker state
This commit is contained in:
Mohamed Boudra
2026-06-04 15:16:41 +08:00
committed by GitHub
parent 0b62e3bd7f
commit 38f8179bc1
28 changed files with 1392 additions and 424 deletions
+450 -39
View File
@@ -5,11 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { FileDropZone } from "@/components/file-drop-zone";
import { ProjectIconView } from "@/components/project-icon-view";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -21,15 +22,29 @@ import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import {
navigateToWorkspace,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import {
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
@@ -58,7 +73,7 @@ function resolveCheckoutRequest(
interface NewWorkspaceScreenProps {
serverId: string;
sourceDirectory: string;
sourceDirectory?: string;
projectId?: string;
displayName?: string;
}
@@ -68,13 +83,38 @@ interface PickerOptionData {
itemById: Map<string, PickerItem>;
}
interface ProjectOptionData {
options: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
}
interface PickerSelection {
item: PickerItem;
attachedPrNumber: number | null;
}
interface NewWorkspaceProjectPickerInput {
serverId: string;
sourceDirectory?: string;
projectId?: string;
displayName?: string;
}
interface NewWorkspaceProjectPickerState {
projects: HostProjectListItem[];
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
selectedDisplayName: string;
projectPickerOptions: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_OPTION_PREFIX = "project:";
function RefPickerBadgeContent({
selectedItem,
@@ -150,6 +190,68 @@ function RefPickerTrigger({
);
}
function ProjectPickerTrigger({
pickerAnchorRef,
onPress,
disabled,
badgePressableStyle,
label,
projectKey,
iconDataUri,
iconColor,
iconSize,
}: {
pickerAnchorRef: React.RefObject<View | null>;
onPress: () => void;
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
label: string;
projectKey: string | null;
iconDataUri: string | null;
iconColor: string;
iconSize: number;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
return (
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={pickerAnchorRef}
testID="new-workspace-project-picker-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
accessibilityRole="button"
accessibilityLabel="Workspace project"
>
<View style={styles.badgeIconBox}>
{projectKey ? (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.badgeProjectIcon}
fallbackStyle={styles.badgeProjectIconFallback}
textStyle={styles.badgeProjectIconFallbackText}
/>
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
<ChevronDown size={iconSize} color={iconColor} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Choose project</Text>
</TooltipContent>
</Tooltip>
);
}
function CheckoutHintBadge({
prNumber,
onAccept,
@@ -239,6 +341,59 @@ function PickerOptionItem({
);
}
function ProjectOptionItem({
testID,
projectKey,
iconDataUri,
label,
description,
selected,
active,
disabled,
onPress,
}: {
testID: string;
projectKey: string;
iconDataUri: string | null;
label: string;
description: string | undefined;
selected: boolean;
active: boolean;
disabled: boolean;
onPress: () => void;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
const leadingSlot = useMemo(
() => (
<View style={styles.rowIconBox}>
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.projectOptionIcon}
fallbackStyle={styles.projectOptionIconFallback}
textStyle={styles.projectOptionIconFallbackText}
/>
</View>
),
[iconDataUri, placeholderInitial, projectKey],
);
return (
<ComboboxItem
testID={testID}
label={label}
description={description}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
function branchOptionId(name: string): string {
return `${BRANCH_OPTION_PREFIX}${name}`;
}
@@ -247,6 +402,10 @@ function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`;
}
@@ -256,7 +415,7 @@ function pickerItemLabel(item: PickerItem): string {
}
function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
return pickerItemLabel(item);
}
function computePickerOptionData(
@@ -292,6 +451,108 @@ function computePickerOptionData(
return { options: timedOptions.map((t) => t.option), itemById: idMap };
}
function computeProjectOptionData(projects: readonly HostProjectListItem[]): ProjectOptionData {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function useNewWorkspaceProjectPicker({
serverId,
sourceDirectory,
projectId,
displayName: displayNameProp,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
const displayName = displayNameProp?.trim() ?? "";
const projects = useHostProjects(serverId || null);
const lastWorkspaceSelection = useLastWorkspaceSelection();
const lastWorkspaceServerId = lastWorkspaceSelection?.serverId === serverId ? serverId : null;
const lastWorkspaceId =
lastWorkspaceSelection?.serverId === serverId ? lastWorkspaceSelection.workspaceId : null;
const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId);
const routeProject = useMemo(
() =>
hostProjectFromRoute({
serverId,
projectId,
displayName,
sourceDirectory,
}),
[displayName, projectId, serverId, sourceDirectory],
);
const lastActiveProject = useMemo(
() => hostProjectFromWorkspace({ serverId, workspace: lastWorkspace }),
[lastWorkspace, serverId],
);
const initialProject = useMemo(
() =>
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects,
}),
[lastActiveProject, projects, routeProject],
);
const worktreeProjects = useMemo(
() => projects.filter((project) => project.canCreateWorktree),
[projects],
);
const selectedProjectKey = manualProjectKey ?? initialProject?.projectKey ?? null;
const selectedProject = useMemo(
() =>
resolveSelectedHostProject({
selectedProjectKey,
projects,
routeProject,
lastActiveProject,
}),
[lastActiveProject, projects, routeProject, selectedProjectKey],
);
const { options: projectPickerOptions, projectByOptionId }: ProjectOptionData = useMemo(
() => computeProjectOptionData(worktreeProjects),
[worktreeProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project?.canCreateWorktree) return;
setManualProjectKey(project.projectKey);
},
[projectByOptionId],
);
return {
projects,
selectedProject,
selectedSourceDirectory: selectedProject?.iconWorkingDir ?? null,
selectedDisplayName: selectedProject?.projectName ?? displayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
if (input.isCompact) {
return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }];
}
return [styles.content, styles.contentCentered];
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
}
function normalizeBranchDetails(
data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
@@ -378,32 +639,31 @@ function buildComposerConfig(input: {
serverId: string;
isConnected: boolean;
workspaceDirectory: string | null;
sourceDirectory: string;
sourceDirectory: string | null;
}): Parameters<typeof useAgentInputDraft>[0]["composer"] {
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
const workingDir = workspaceDirectory || sourceDirectory || undefined;
return {
initialServerId: serverId || null,
initialValues:
workspaceDirectory || sourceDirectory
? { workingDir: workspaceDirectory || sourceDirectory }
: undefined,
initialValues: workingDir ? { workingDir } : undefined,
isVisible: true,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
lockedWorkingDir: workingDir,
};
}
function computeWorkspaceTitle(
workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | null,
displayName: string,
sourceDirectory: string,
sourceDirectory: string | null,
): string {
const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null;
return (
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
sourceDirectory.split(/[\\/]/).findLast(Boolean) ||
sourceDirectory
fallbackDirectoryName ||
"Choose project"
);
}
@@ -503,7 +763,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
export function NewWorkspaceScreen({
serverId,
sourceDirectory,
sourceDirectory: sourceDirectoryProp,
projectId,
displayName: displayNameProp,
}: NewWorkspaceScreenProps) {
@@ -519,9 +779,11 @@ export function NewWorkspaceScreen({
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
const pickerAnchorRef = useRef<View>(null);
const projectPickerAnchorRef = useRef<View>(null);
useEffect(() => {
const trimmed = pickerSearchQuery.trim();
@@ -529,26 +791,42 @@ export function NewWorkspaceScreen({
return () => clearTimeout(timer);
}, [pickerSearchQuery]);
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
const {
projects,
selectedProject,
selectedSourceDirectory,
selectedDisplayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId,
projectTriggerLabel,
handleSelectProjectOption: selectProjectOption,
} = useNewWorkspaceProjectPicker({
serverId,
sourceDirectory: sourceDirectoryProp,
projectId,
displayName: displayNameProp,
});
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
const draftKey = `new-workspace:${serverId}:${selectedSourceDirectory ?? "choose-project"}`;
const chatDraft = useAgentInputDraft({
draftKey,
composer: buildComposerConfig({
serverId,
isConnected,
workspaceDirectory: workspace?.workspaceDirectory ?? null,
sourceDirectory,
sourceDirectory: selectedSourceDirectory,
}),
});
const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments);
const selectedItem = manualPickerSelection?.item ?? null;
const selectedItem = getSelectedPickerItem(manualPickerSelection);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
@@ -558,15 +836,19 @@ export function NewWorkspaceScreen({
}, [client, isConnected]);
const clientReady = isConnected && Boolean(client);
const pickerQueryEnabled = pickerOpen && clientReady;
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory;
const checkoutStatusQuery = useQuery({
queryKey: ["checkout-status", serverId, sourceDirectory],
queryKey: ["checkout-status", serverId, selectedSourceDirectory],
queryFn: async () => {
if (!selectedSourceDirectory) {
throw new Error("Choose a project");
}
const connectedClient = withConnectedClient();
return connectedClient.getCheckoutStatus(sourceDirectory);
return connectedClient.getCheckoutStatus(selectedSourceDirectory);
},
enabled: clientReady,
enabled: clientReady && hasSelectedSourceDirectory,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
@@ -576,11 +858,14 @@ export function NewWorkspaceScreen({
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => {
if (!selectedSourceDirectory) {
throw new Error("Choose a project");
}
const connectedClient = withConnectedClient();
return connectedClient.getBranchSuggestions({
cwd: sourceDirectory,
cwd: selectedSourceDirectory,
query: debouncedPickerSearchQuery || undefined,
limit: 20,
});
@@ -592,7 +877,7 @@ export function NewWorkspaceScreen({
const githubPrSearchQuery = useGithubSearchQuery({
client,
serverId,
cwd: sourceDirectory,
cwd: selectedSourceDirectory ?? "",
query: debouncedPickerSearchQuery,
kinds: ["github-pr"],
enabled: pickerQueryEnabled,
@@ -612,7 +897,6 @@ export function NewWorkspaceScreen({
() => computePickerOptionData(branchDetails, prItems),
[branchDetails, prItems],
);
const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main";
@@ -624,7 +908,6 @@ export function NewWorkspaceScreen({
? branchOptionId(selectedItem.name)
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const selectPickerItem = useCallback(
(item: PickerItem) => {
const next = syncPickerPrAttachment({
@@ -654,6 +937,17 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project?.canCreateWorktree) return;
selectProjectOption(id);
setProjectPickerOpen(false);
setManualPickerSelection(null);
},
[projectByOptionId, selectProjectOption],
);
const checkoutHintPrAttachment = useMemo(
() =>
findCheckoutHintPrAttachment({
@@ -684,6 +978,10 @@ export function NewWorkspaceScreen({
setPickerOpen(true);
}, []);
const openProjectPicker = useCallback(() => {
setProjectPickerOpen(true);
}, []);
const handleClearDraft = useCallback(() => {
// No-op: screen navigates away on success, text should stay for retry on error
}, []);
@@ -705,19 +1003,26 @@ export function NewWorkspaceScreen({
}
}, []);
const handleProjectPickerOpenChange = useCallback((nextOpen: boolean) => {
setProjectPickerOpen(nextOpen);
}, []);
const buildCreateWorktreeInput = useCallback(
(input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
}): CreatePaseoWorktreeInput => {
if (!selectedProject) {
throw new Error("Choose a project");
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const trimmedPrompt = input.prompt.trim();
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
return {
cwd: input.cwd,
...(projectId ? { projectId } : {}),
cwd: selectedProject.iconWorkingDir,
projectId: selectedProject.projectKey,
worktreeSlug: createNameId(),
...(hasFirstAgentContext
? {
@@ -730,7 +1035,7 @@ export function NewWorkspaceScreen({
...checkoutRequest,
};
},
[currentBranch, projectId, selectedItem],
[currentBranch, selectedItem, selectedProject],
);
const ensureWorkspace = useCallback(
@@ -783,7 +1088,11 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, serverId, toast],
);
const workspaceTitle = computeWorkspaceTitle(workspace, displayName, sourceDirectory);
const workspaceTitle = computeWorkspaceTitle(
workspace,
selectedDisplayName,
selectedSourceDirectory,
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
@@ -835,12 +1144,40 @@ export function NewWorkspaceScreen({
[isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm],
);
const renderProjectOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOptionType;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const project = projectByOptionId.get(option.id);
if (!project) return <View key={option.id} />;
return (
<ProjectOptionItem
testID={`new-workspace-project-picker-option-${project.projectKey}`}
projectKey={project.projectKey}
iconDataUri={projectIconDataByProjectKey.get(project.projectKey) ?? null}
label={project.projectName}
description={project.iconWorkingDir}
selected={selected}
active={active}
disabled={isPending || !project.canCreateWorktree}
onPress={onPress}
/>
);
},
[isPending, projectByOptionId, projectIconDataByProjectKey],
);
const contentStyle = useMemo(
() => [
styles.content,
isCompact ? styles.contentCompact : styles.contentCentered,
isCompact ? { paddingBottom: insets.bottom } : null,
],
() => getContentStyle({ isCompact, insetBottom: insets.bottom }),
[isCompact, insets.bottom],
);
@@ -863,11 +1200,42 @@ export function NewWorkspaceScreen({
const composerFooter = useMemo(
() => (
<View testID="new-workspace-ref-picker-row" style={styles.optionsRow}>
<View>
<ProjectPickerTrigger
pickerAnchorRef={projectPickerAnchorRef}
onPress={openProjectPicker}
disabled={isPending || projectPickerOptions.length === 0}
badgePressableStyle={badgePressableStyle}
label={projectTriggerLabel}
projectKey={selectedProject?.projectKey ?? null}
iconDataUri={
selectedProject
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
: null
}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={projectPickerOptions}
value={selectedProjectOptionId}
onSelect={handleSelectProjectOption}
searchable
searchPlaceholder="Search projects"
title="Project"
open={projectPickerOpen}
onOpenChange={handleProjectPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={projectPickerAnchorRef}
emptyText="No projects available."
renderOption={renderProjectOption}
/>
</View>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending}
disabled={isPending || !selectedSourceDirectory}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
@@ -910,15 +1278,26 @@ export function NewWorkspaceScreen({
checkoutHintPrAttachment,
dismissCheckoutHint,
handlePickerOpenChange,
handleProjectPickerOpenChange,
handleSelectOption,
handleSelectProjectOption,
isPending,
openPicker,
openProjectPicker,
options,
pickerEmptyText,
pickerOpen,
projectPickerOpen,
projectPickerOptions,
projectTriggerLabel,
projectIconDataByProjectKey,
renderPickerOption,
renderProjectOption,
selectedItem,
selectedOptionId,
selectedProject,
selectedProjectOptionId,
selectedSourceDirectory,
setPickerSearchQuery,
agentControlsWithDisabled,
theme.colors.foregroundMuted,
@@ -951,7 +1330,7 @@ export function NewWorkspaceScreen({
<TitlebarDragRegion />
<View style={styles.centered}>
<Composer
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
agentId={draftKey}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
@@ -965,7 +1344,7 @@ export function NewWorkspaceScreen({
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
@@ -1089,10 +1468,42 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
flexShrink: 0,
},
badgeProjectIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
},
badgeProjectIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
badgeProjectIconFallbackText: {
fontSize: 10,
fontWeight: "600",
},
rowIconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
projectOptionIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
},
projectOptionIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
projectOptionIconFallbackText: {
fontSize: 10,
fontWeight: "600",
},
}));
@@ -5,7 +5,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -29,6 +28,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -219,6 +219,21 @@ function ProjectSettingsBody({
});
const data = readQuery.data;
const projectIconTargets = useMemo(
() => [
{
serverId: selectedHost.serverId,
projectKey: project.projectKey,
iconWorkingDir: selectedHost.repoRoot,
},
],
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
@@ -236,7 +251,7 @@ function ProjectSettingsBody({
<View style={styles.headerBlock}>
<View style={styles.titleRow}>
<ProjectTitleIcon
host={selectedHost}
iconDataUri={projectIconDataUri}
projectName={project.projectName}
projectKey={project.projectKey}
/>
@@ -886,19 +901,18 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
}
function ProjectTitleIcon({
host,
iconDataUri,
projectName,
projectKey,
}: {
host: ProjectHostEntry;
iconDataUri: string | null;
projectName: string;
projectKey: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
return (
<ProjectIconView
iconDataUri={projectIconToDataUri(icon)}
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
imageStyle={styles.titleIcon}
@@ -188,9 +188,8 @@ vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
projectIconToDataUri: () => null,
vi.mock("@/projects/project-icons", () => ({
useProjectIconDataByProjectKey: () => new Map(),
}));
import ProjectsScreen from "./projects-screen";
+34 -14
View File
@@ -1,15 +1,15 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
import type { ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
@@ -18,6 +18,25 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
const host = project.hosts[0];
if (!host) return [];
return [
{
serverId: host.serverId,
projectKey: project.projectKey,
iconWorkingDir: host.repoRoot,
},
];
}),
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
serverId: null,
projects: iconTargets,
});
if (isLoading && projects.length === 0) {
return (
@@ -45,6 +64,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null}
/>
))}
</View>
@@ -68,12 +88,12 @@ interface ProjectRowProps {
project: ProjectSummary;
isFirst: boolean;
isSelected: boolean;
iconDataUri: string | null;
}
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
const { theme } = useUnistyles();
const { hosts, projectKey, projectName } = project;
const leadingHost = hosts[0];
const { projectKey, projectName } = project;
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
@@ -102,7 +122,11 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
>
<View style={styles.rowMain}>
<View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} projectKey={projectKey} />
<ProjectRowIcon
iconDataUri={iconDataUri}
projectName={projectName}
projectKey={projectKey}
/>
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{projectName}
@@ -114,22 +138,18 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
}
function ProjectRowIcon({
host,
iconDataUri,
projectName,
projectKey,
}: {
host: ProjectHostEntry | undefined;
iconDataUri: string | null;
projectName: string;
projectKey: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({
serverId: host?.serverId ?? "",
cwd: host?.repoRoot ?? "",
});
return (
<ProjectIconView
iconDataUri={projectIconToDataUri(icon)}
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
imageStyle={styles.iconImage}