From 8d0908af9df840d2e9334362941d1abd5a438a8a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 17:25:47 -0500 Subject: [PATCH 1/6] Refine sub-project creation scope handling --- src/browser/App.tsx | 3 +- .../DraggableSection/DraggableSection.tsx | 85 --------- .../ProjectSidebar/ProjectSidebar.test.tsx | 8 - .../ProjectSidebar/ProjectSidebar.tsx | 175 +++++++----------- .../SectionDragLayer/SectionDragLayer.tsx | 53 ------ src/browser/contexts/ProjectContext.test.tsx | 15 ++ src/browser/contexts/ProjectContext.tsx | 7 +- src/browser/contexts/WorkspaceContext.tsx | 91 ++++++--- src/browser/features/ChatInput/index.tsx | 15 +- .../Settings/Sections/SecretsSection.tsx | 15 +- .../hooks/useStartWorkspaceCreation.test.ts | 9 +- .../hooks/useStartWorkspaceCreation.ts | 17 +- src/browser/utils/commands/sources.test.ts | 43 ++++- src/browser/utils/commands/sources.ts | 21 ++- src/common/utils/subProjects.test.ts | 36 ++++ src/common/utils/subProjects.ts | 41 ++++ src/node/services/projectService.test.ts | 83 +++++++++ src/node/services/projectService.ts | 21 ++- 18 files changed, 416 insertions(+), 322 deletions(-) delete mode 100644 src/browser/components/DraggableSection/DraggableSection.tsx delete mode 100644 src/browser/components/SectionDragLayer/SectionDragLayer.tsx diff --git a/src/browser/App.tsx b/src/browser/App.tsx index eda286922d..c28f7eac6d 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -42,6 +42,7 @@ import { } from "@/constants/layout"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; +import { getTopLevelProjectEntries } from "@/common/utils/subProjects"; import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; @@ -1237,7 +1238,7 @@ function AppInner() { setMultiProjectWorkspaceModalOpen(false)} - projectOptions={Array.from(userProjects.keys()).map((projectPath) => ({ + projectOptions={getTopLevelProjectEntries(userProjects).map(([projectPath]) => ({ projectPath, projectName: projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Unnamed Project", diff --git a/src/browser/components/DraggableSection/DraggableSection.tsx b/src/browser/components/DraggableSection/DraggableSection.tsx deleted file mode 100644 index 3dbf39482f..0000000000 --- a/src/browser/components/DraggableSection/DraggableSection.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useEffect } from "react"; -import { useDrag, useDrop } from "react-dnd"; -import { getEmptyImage } from "react-dnd-html5-backend"; -import { cn } from "@/common/lib/utils"; - -const SECTION_DRAG_TYPE = "SECTION_REORDER"; - -export interface SectionDragItem { - type: typeof SECTION_DRAG_TYPE; - sectionId: string; - sectionName: string; - projectPath: string; -} - -interface DraggableSectionProps { - sectionId: string; - sectionName: string; - projectPath: string; - /** Called when a section is dropped onto this section (reorder) */ - onReorder: (draggedSectionId: string, targetSectionId: string) => void; - children: React.ReactNode; -} - -/** - * Wrapper that makes a section draggable for reordering. - * Sections can be dragged and dropped onto other sections within the same project. - */ -export const DraggableSection: React.FC = ({ - sectionId, - sectionName, - projectPath, - onReorder, - children, -}) => { - const [{ isDragging }, drag, dragPreview] = useDrag( - () => ({ - type: SECTION_DRAG_TYPE, - item: { - type: SECTION_DRAG_TYPE, - sectionId, - sectionName, - projectPath, - } satisfies SectionDragItem, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [sectionId, sectionName, projectPath] - ); - - // Hide native drag preview - useEffect(() => { - dragPreview(getEmptyImage(), { captureDraggingState: true }); - }, [dragPreview]); - - const [{ isOver, canDrop }, drop] = useDrop( - () => ({ - accept: SECTION_DRAG_TYPE, - canDrop: (item: SectionDragItem) => { - // Can only drop if from same project and different section - return item.projectPath === projectPath && item.sectionId !== sectionId; - }, - drop: (item: SectionDragItem) => { - onReorder(item.sectionId, sectionId); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [projectPath, sectionId, onReorder] - ); - - return ( -
drag(drop(node))} - data-section-drag-id={sectionId} - className={cn(isDragging && "opacity-50", isOver && canDrop && "bg-accent/10")} - > - {children} -
- ); -}; - -export { SECTION_DRAG_TYPE }; diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index c914f94558..d587675acc 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -35,8 +35,6 @@ import * as PopoverErrorModule from "../PopoverError/PopoverError"; import * as SectionHeaderModule from "../SectionHeader/SectionHeader"; import * as WorkspaceSectionDropZoneModule from "../WorkspaceSectionDropZone/WorkspaceSectionDropZone"; import * as WorkspaceDragLayerModule from "../WorkspaceDragLayer/WorkspaceDragLayer"; -import * as SectionDragLayerModule from "../SectionDragLayer/SectionDragLayer"; -import * as DraggableSectionModule from "../DraggableSection/DraggableSection"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import type ProjectSidebarComponent from "./ProjectSidebar"; import type * as WorkspaceStatusIndicatorModuleExports from "../WorkspaceStatusIndicator/WorkspaceStatusIndicator"; @@ -521,12 +519,6 @@ function installProjectSidebarTestDoubles() { spyOn(WorkspaceDragLayerModule, "WorkspaceDragLayer").mockImplementation( (() => null) as unknown as typeof WorkspaceDragLayerModule.WorkspaceDragLayer ); - spyOn(SectionDragLayerModule, "SectionDragLayer").mockImplementation( - (() => null) as unknown as typeof SectionDragLayerModule.SectionDragLayer - ); - spyOn(DraggableSectionModule, "DraggableSection").mockImplementation( - TestWrapper as unknown as typeof DraggableSectionModule.DraggableSection - ); void mock.module("../PositionedMenu/PositionedMenu", () => ({ PositionedMenu: (props: { open: boolean; children: React.ReactNode }) => props.open ?
{props.children}
: null, diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index b218c8393f..bdba118248 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -100,8 +100,6 @@ import { PopoverError } from "../PopoverError/PopoverError"; import { SectionHeader } from "../SectionHeader/SectionHeader"; import { WorkspaceSectionDropZone } from "../WorkspaceSectionDropZone/WorkspaceSectionDropZone"; import { WorkspaceDragLayer } from "../WorkspaceDragLayer/WorkspaceDragLayer"; -import { SectionDragLayer } from "../SectionDragLayer/SectionDragLayer"; -import { DraggableSection } from "../DraggableSection/DraggableSection"; import { Separator } from "../Separator/Separator"; import { ScrollArea } from "../ScrollArea/ScrollArea"; import { getProjectDisplayName, getSubProjectsForParent } from "@/common/utils/subProjects"; @@ -472,12 +470,7 @@ interface ProjectDragItem { type: "PROJECT"; projectPath: string; } -interface SectionDragItemLocal { - type: "SECTION_REORDER"; - sectionId: string; - projectPath: string; -} -type DragItem = ProjectDragItem | SectionDragItemLocal | null; +type DragItem = ProjectDragItem | null; const ProjectDragLayer: React.FC = () => { const dragState = useDragLayer<{ @@ -813,8 +806,8 @@ const ProjectSidebarInner: React.FC = ({ // Wrapper to close sidebar on mobile after opening an existing draft const handleOpenWorkspaceDraft = useCallback( - (projectPath: string, draftId: string, sectionId?: string | null) => { - openWorkspaceDraft(projectPath, draftId, sectionId); + (projectPath: string, draftId: string) => { + openWorkspaceDraft(projectPath, draftId); if (window.innerWidth <= MOBILE_BREAKPOINT && !collapsed) { persistMobileSidebarScrollTop(mobileScrollTopRef.current); onToggleCollapsed(); @@ -1703,7 +1696,6 @@ const ProjectSidebarInner: React.FC = ({ -
= ({ ); }} onOpen={() => - handleOpenWorkspaceDraft( - projectPath, - draft.draftId, - sectionId - ) + handleOpenWorkspaceDraft(projectPath, draft.draftId) } onDelete={() => { if (isSelected) { @@ -2445,11 +2433,7 @@ const ProjectSidebarInner: React.FC = ({ : undefined; if (fallback) { - openWorkspaceDraft( - projectPath, - fallback.draftId, - normalizeDraftSectionId(fallback) - ); + openWorkspaceDraft(projectPath, fallback.draftId); } else { navigateToProject(sectionId ?? projectPath); } @@ -2738,11 +2722,6 @@ const ProjectSidebarInner: React.FC = ({ })(); }; - // Sub-project ordering is path/display-name derived in v1; no manual reorder. - const handleSectionReorder = () => { - // Sub-project ordering is alphabetical by display name in v1. - }; - // Render section with its workspaces const renderSection = (section: SectionConfig) => { const sectionWorkspaces = bySectionId.get(section.id) ?? []; @@ -2771,92 +2750,76 @@ const ProjectSidebarInner: React.FC = ({ autoEditingSection?.sectionId === section.id; return ( - - - - toggleSection(projectPath, section.id) - } - onAddWorkspace={() => { - // Create workspace in this section - handleAddWorkspace(projectPath, section.id); - }} - onRename={(name) => { - if (shouldAutoEditSection) { - setAutoEditingSection(null); - } - void updateDisplayName(section.id, name); - }} - onChangeColor={(color) => { - void updateProjectColor(section.id, color); - }} - autoStartEditing={shouldAutoEditSection} - onAutoCreateAbandon={ - shouldAutoEditSection - ? () => { - void (async () => { - setAutoEditingSection(null); - await handleRemoveSection( - projectPath, - section.id - ); - })(); - } - : undefined + toggleSection(projectPath, section.id)} + onAddWorkspace={() => { + // Create workspace in this section + handleAddWorkspace(projectPath, section.id); + }} + onRename={(name) => { + if (shouldAutoEditSection) { + setAutoEditingSection(null); } - onAutoCreateRenameCancel={ - shouldAutoEditSection - ? () => { + void updateDisplayName(section.id, name); + }} + onChangeColor={(color) => { + void updateProjectColor(section.id, color); + }} + autoStartEditing={shouldAutoEditSection} + onAutoCreateAbandon={ + shouldAutoEditSection + ? () => { + void (async () => { setAutoEditingSection(null); - } - : undefined - } - onDelete={(anchorEl) => { - void handleRemoveSection( - projectPath, + await handleRemoveSection(projectPath, section.id); + })(); + } + : undefined + } + onAutoCreateRenameCancel={ + shouldAutoEditSection + ? () => { + setAutoEditingSection(null); + } + : undefined + } + onDelete={(anchorEl) => { + void handleRemoveSection(projectPath, section.id, anchorEl); + }} + /> + {isSectionExpanded && ( +
+ {sectionDrafts.map((draft) => renderDraft(draft))} + {sectionWorkspaces.length > 0 ? ( + renderAgeTiers( + sectionWorkspaces, + getSectionTierKey(projectPath, section.id, 0).replace( + ":tier:0", + ":tier" + ), section.id, - anchorEl - ); - }} - /> - {isSectionExpanded && ( -
- {sectionDrafts.map((draft) => renderDraft(draft))} - {sectionWorkspaces.length > 0 ? ( - renderAgeTiers( - sectionWorkspaces, - getSectionTierKey(projectPath, section.id, 0).replace( - ":tier:0", - ":tier" - ), - section.id, - sectionAllWorkspaces - ) - ) : sectionDrafts.length === 0 ? ( -
- No chats in this sub-project -
- ) : null} -
- )} - - + sectionAllWorkspaces + ) + ) : sectionDrafts.length === 0 ? ( +
+ No chats in this sub-project +
+ ) : null} +
+ )} +
); }; diff --git a/src/browser/components/SectionDragLayer/SectionDragLayer.tsx b/src/browser/components/SectionDragLayer/SectionDragLayer.tsx deleted file mode 100644 index 9b858d3c2d..0000000000 --- a/src/browser/components/SectionDragLayer/SectionDragLayer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { useDragLayer } from "react-dnd"; -import { cn } from "@/common/lib/utils"; -import { SECTION_DRAG_TYPE, type SectionDragItem } from "../DraggableSection/DraggableSection"; -import { ChevronRight } from "lucide-react"; - -/** - * Custom drag layer for section drag-drop reordering. - * Renders a preview of the section being dragged. - */ -export const SectionDragLayer: React.FC = () => { - const dragState = useDragLayer<{ - isDragging: boolean; - item: unknown; - itemType: string | symbol | null; - currentOffset: { x: number; y: number } | null; - }>((monitor) => ({ - isDragging: monitor.isDragging(), - item: monitor.getItem(), - itemType: monitor.getItemType(), - currentOffset: monitor.getClientOffset(), - })); - - const { isDragging, item, itemType, currentOffset } = dragState; - - // Only render for section drags - if (!isDragging || itemType !== SECTION_DRAG_TYPE || !currentOffset) { - return null; - } - - const sectionItem = item as SectionDragItem & { sectionName?: string }; - const displayName = sectionItem.sectionName ?? "Section"; - - return ( -
-
-
- - {displayName} -
-
-
- ); -}; diff --git a/src/browser/contexts/ProjectContext.test.tsx b/src/browser/contexts/ProjectContext.test.tsx index f9e9ef7e11..4d7a1c9b2e 100644 --- a/src/browser/contexts/ProjectContext.test.tsx +++ b/src/browser/contexts/ProjectContext.test.tsx @@ -615,6 +615,21 @@ describe("ProjectContext", () => { expect(result).toBe("/user-proj"); }); + test("resolveNewChatProjectPath skips sub-projects for unscoped fallback", async () => { + createMockAPI({ + list: () => + Promise.resolve([ + ["/repo/packages/api", { workspaces: [], parentProjectPath: "/repo" }], + ["/repo", { workspaces: [] }], + ]), + }); + + const ctx = await setup(); + await waitFor(() => expect(ctx().userProjects.size).toBe(2)); + + expect(ctx().resolveNewChatProjectPath({})).toBe("/repo"); + }); + test("resolveNewChatProjectPath returns null when no user projects exist", async () => { createMockAPI({ list: () => Promise.resolve([["/system-only", { workspaces: [], projectKind: "system" }]]), diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index 842d11ac37..8e0d342f69 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -23,6 +23,7 @@ import { } from "@/common/constants/storage"; import { getErrorMessage } from "@/common/utils/errors"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; +import { getFirstTopLevelProjectPath } from "@/common/utils/subProjects"; import { normalizeProjectPathForComparison, resolveProjectPathFromProjectQuery, @@ -311,10 +312,10 @@ export function ProjectProvider(props: { children: ReactNode }) { const hasAnyProject = allProjectsInternal.size > 0; - // Default project selection should only target user-visible projects. + // Default project selection should target parent-owned projects. Sub-projects + // are displayed as sections under their parent rather than standalone routes. const resolveDefaultProjectPath = useCallback(() => { - const firstUser = userProjects.keys().next().value; - return typeof firstUser === "string" ? firstUser : null; + return getFirstTopLevelProjectPath(userProjects); }, [userProjects]); // Canonical resolver for new-chat deep links: explicit selectors first, default fallback last. diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index b1f6919999..e156fab327 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -69,6 +69,7 @@ import { useRouter } from "@/browser/contexts/RouterContext"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; import { normalizeAgentId } from "@/common/utils/agentIds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { resolveWorkspaceCreationScope } from "@/common/utils/subProjects"; import type { APIClient } from "@/browser/contexts/API"; import { getErrorMessage } from "@/common/utils/errors"; @@ -472,7 +473,7 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue { /** Draft ID to open when creating a UI-only workspace draft (from URL) */ pendingNewWorkspaceDraftId: string | null; /** Legacy entry point: open the creation screen (no new draft is created) */ - beginWorkspaceCreation: (projectPath: string, subProjectPath?: string) => void; + beginWorkspaceCreation: (projectPath: string) => void; // UI-only workspace creation drafts (placeholders) workspaceDraftsByProject: WorkspaceDraftsByProject; @@ -486,11 +487,7 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue { draftId: string, subProjectPath: string | null ) => void; - openWorkspaceDraft: ( - projectPath: string, - draftId: string, - subProjectPath?: string | null - ) => void; + openWorkspaceDraft: (projectPath: string, draftId: string) => void; deleteWorkspaceDraft: (projectPath: string, draftId: string) => void; // Helpers @@ -538,11 +535,16 @@ function shouldBlockStartupAutoNavigation(options: { ); } -function getMostRecentVisibleProjectPath( +interface WorkspaceRouteScope { + projectPath: string; + subProjectPath: string | null; +} + +function getMostRecentVisibleWorkspaceScope( workspaceMetadata: Map, workspaceRecency: Record, getProjectConfig: (projectPath: string) => { projectKind?: "user" | "system" } | undefined -): string | null { +): WorkspaceRouteScope | null { const recentWorkspace = [...workspaceMetadata.values()] .filter((workspace) => { const projectConfig = getProjectConfig(workspace.projectPath); @@ -569,7 +571,12 @@ function getMostRecentVisibleProjectPath( return bCreatedAt - aCreatedAt; })[0]; - return recentWorkspace?.projectPath ?? null; + return recentWorkspace + ? { + projectPath: recentWorkspace.projectPath, + subProjectPath: recentWorkspace.subProjectPath ?? null, + } + : null; } export function WorkspaceProvider(props: WorkspaceProviderProps) { @@ -723,10 +730,17 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { } const resolvedProjectConfig = getProjectConfig(resolvedProjectPath); - const owningProjectPath = resolvedProjectConfig?.parentProjectPath ?? resolvedProjectPath; - const normalizedSubProjectPath: string | null = resolvedProjectConfig?.parentProjectPath - ? resolvedProjectPath - : null; + const projectsForScope = new Map>(); + if (resolvedProjectConfig) { + projectsForScope.set(resolvedProjectPath, resolvedProjectConfig); + } + const creationScope = resolveWorkspaceCreationScope( + resolvedProjectPath, + projectsForScope, + null + ); + const owningProjectPath = creationScope.projectPath; + const normalizedSubProjectPath = creationScope.subProjectPath; // IMPORTANT: Deep links should always create a fresh draft, even if an existing draft // is empty. This keeps deep-link navigations predictable and avoids surprising reuse. @@ -878,11 +892,15 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // removed/unregistered. WorkspaceService.create rejects unknown sub-project // paths under the parent, so leaking a stale value would brick reopening a // legacy draft. Coerce to null and let creation fall back to the parent cwd. - const pendingNewWorkspaceSubProjectPath = - pendingNewWorkspaceSubProjectPathRaw && - getProjectConfig(pendingNewWorkspaceSubProjectPathRaw) != null + const pendingNewWorkspaceSubProjectPath = (() => { + if (!pendingNewWorkspaceProject || !pendingNewWorkspaceSubProjectPathRaw) { + return null; + } + const pendingSubProjectConfig = getProjectConfig(pendingNewWorkspaceSubProjectPathRaw); + return pendingSubProjectConfig?.parentProjectPath === pendingNewWorkspaceProject ? pendingNewWorkspaceSubProjectPathRaw : null; + })(); // selectedWorkspace is derived from currentWorkspaceId in URL + workspaceMetadata const selectedWorkspace = useMemo(() => { @@ -898,13 +916,18 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { isAnalyticsOpen || pendingNewWorkspaceProject != null; - const resolveFallbackProjectPath = useCallback(() => { - const recentProjectPath = getMostRecentVisibleProjectPath( + const resolveFallbackWorkspaceScope = useCallback((): WorkspaceRouteScope | null => { + const recentScope = getMostRecentVisibleWorkspaceScope( workspaceMetadata, workspaceStore.getWorkspaceRecency(), getProjectConfig ); - return recentProjectPath ?? resolveNewChatProjectPath({}); + if (recentScope) { + return recentScope; + } + + const projectPath = resolveNewChatProjectPath({}); + return projectPath ? { projectPath, subProjectPath: null } : null; }, [workspaceMetadata, workspaceStore, getProjectConfig, resolveNewChatProjectPath]); // Keep a ref to the current selectedWorkspace for use in functional updates. @@ -1549,7 +1572,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [] ); const beginWorkspaceCreation = useCallback( - (projectPath: string, _subProjectPath?: string) => { + (projectPath: string) => { navigateToProject(projectPath); }, [navigateToProject] @@ -1691,19 +1714,21 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if (cancelled) return; - const fallbackProjectPath = resolveFallbackProjectPath(); - if (!fallbackProjectPath) return; + const fallbackScope = resolveFallbackWorkspaceScope(); + if (!fallbackScope) return; hasHandledStartupRootRouteRef.current = true; // The old landing page is gone. Treat "/" as a compatibility entrypoint and // immediately replace it with a concrete project route instead of rendering a dashboard. if (behavior === "new-chat") { - createWorkspaceDraft(fallbackProjectPath, undefined, { replace: true }); + createWorkspaceDraft(fallbackScope.projectPath, fallbackScope.subProjectPath ?? undefined, { + replace: true, + }); return; } - navigateToProject(fallbackProjectPath, undefined, { replace: true }); + navigateToProject(fallbackScope.projectPath, undefined, { replace: true }); }; void resolveStartupRootRoute(); @@ -1716,7 +1741,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { loading, projectsLoading, hasBlockingStartupRouteState, - resolveFallbackProjectPath, + resolveFallbackWorkspaceScope, createWorkspaceDraft, navigateToProject, ]); @@ -1729,14 +1754,20 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if (loading || projectsLoading) return; if (location.pathname !== "/") return; - const fallbackProjectPath = resolveFallbackProjectPath(); - if (!fallbackProjectPath) return; + const fallbackScope = resolveFallbackWorkspaceScope(); + if (!fallbackScope) return; - navigateToProject(fallbackProjectPath, undefined, { replace: true }); - }, [loading, projectsLoading, location.pathname, resolveFallbackProjectPath, navigateToProject]); + navigateToProject(fallbackScope.projectPath, undefined, { replace: true }); + }, [ + loading, + projectsLoading, + location.pathname, + resolveFallbackWorkspaceScope, + navigateToProject, + ]); const openWorkspaceDraft = useCallback( - (projectPath: string, draftId: string, _subProjectPath?: string | null) => { + (projectPath: string, draftId: string) => { navigateToProject(projectPath, draftId); }, [navigateToProject] diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index e5b8091ee5..d304543cf9 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -65,6 +65,7 @@ import { getInlineSkillSuggestions, shouldRefreshInlineSkillSuggestions, } from "@/browser/utils/agentSkills/inlineSkillSuggestions"; +import { resolveWorkspaceCreationScope } from "@/common/utils/subProjects"; import { getCommandGhostHint } from "@/browser/utils/slashCommands/registry"; import { getSlashCommandSuggestions, @@ -202,14 +203,14 @@ const ChatInputInner: React.FC = (props) => { ); const { variant } = props; const { userProjects } = useProjectContext(); - const creationProject = variant === "creation" ? userProjects.get(props.projectPath) : undefined; - const creationParentProjectPath = - variant === "creation" ? (creationProject?.parentProjectPath ?? props.projectPath) : ""; - const creationSubProjectPath = + const creationScope = variant === "creation" - ? (props.pendingSubProjectPath ?? - (creationProject?.parentProjectPath ? props.projectPath : undefined)) - : undefined; + ? resolveWorkspaceCreationScope(props.projectPath, userProjects, props.pendingSubProjectPath) + : null; + const creationParentProjectPath = creationScope?.projectPath ?? ""; + const creationSubProjectPath = creationScope?.subProjectPath ?? undefined; + const creationProject = + variant === "creation" ? userProjects.get(creationParentProjectPath) : undefined; const creationProjectPath = creationParentProjectPath; const [thinkingLevel] = useThinkingLevel(); const atMentionProjectPath = variant === "creation" ? props.projectPath : null; diff --git a/src/browser/features/Settings/Sections/SecretsSection.tsx b/src/browser/features/Settings/Sections/SecretsSection.tsx index 6383e1ac79..3eb1831ec1 100644 --- a/src/browser/features/Settings/Sections/SecretsSection.tsx +++ b/src/browser/features/Settings/Sections/SecretsSection.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { formatProjectHierarchyLabel, getTopLevelProjectEntries } from "@/common/utils/subProjects"; type SecretsScope = "global" | "project"; @@ -120,11 +121,13 @@ export const SecretsSection: React.FC = () => { const { api } = useAPI(); const { userProjects } = useProjectContext(); const { secretsProjectPath, setSecretsProjectPath } = useSettings(); - const projectList = Array.from(userProjects.keys()); + const projectList = getTopLevelProjectEntries(userProjects).map(([projectPath]) => projectPath); - // Consume one-shot project scope hint from the sidebar secrets button. + // Consume one-shot project scope hint from the sidebar secrets button. Secrets + // are applied from the parent workspace owner, so sub-projects stay out of the + // project picker until runtime injection supports child-specific secrets. const initialScope: SecretsScope = - secretsProjectPath && userProjects.has(secretsProjectPath) ? "project" : "global"; + secretsProjectPath && projectList.includes(secretsProjectPath) ? "project" : "global"; const initialProject = initialScope === "project" ? secretsProjectPath! : ""; const [scope, setScope] = useState(initialScope); @@ -169,11 +172,11 @@ export const SecretsSection: React.FC = () => { // projects load asynchronously, so we must keep the hint alive until then. useEffect(() => { if (!secretsProjectPath) return; - if (!userProjects.has(secretsProjectPath)) return; + if (!projectList.includes(secretsProjectPath)) return; setScope("project"); setSelectedProject(secretsProjectPath); setSecretsProjectPath(null); - }, [secretsProjectPath, userProjects, setSecretsProjectPath]); + }, [secretsProjectPath, projectList, setSecretsProjectPath]); // Default to the first project when switching into Project scope. useEffect(() => { @@ -677,7 +680,7 @@ export const SecretsSection: React.FC = () => { {projectList.map((path) => ( - {path.split(/[\\/]/).pop() ?? path} + {formatProjectHierarchyLabel(path, userProjects)} ))} diff --git a/src/browser/hooks/useStartWorkspaceCreation.test.ts b/src/browser/hooks/useStartWorkspaceCreation.test.ts index ae1330a6db..0d7e2ae4d9 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.test.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.test.ts @@ -11,7 +11,7 @@ import { getProjectScopeId, getTrunkBranchKey, } from "@/common/constants/storage"; -import type { ProjectConfig } from "@/node/config"; +import type { ProjectConfig } from "@/common/types/project"; import type { updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -79,11 +79,16 @@ describe("persistWorkspaceCreationPrefill", () => { }); describe("getFirstProjectPath", () => { - test("returns first project path or null", () => { + test("returns first top-level project path or null", () => { const emptyProjects = new Map(); expect(getFirstProjectPath(emptyProjects)).toBeNull(); const projects = new Map(); + projects.set("/tmp/a/packages/api", { + path: "/tmp/a/packages/api", + parentProjectPath: "/tmp/a", + workspaces: [], + } as ProjectConfig); projects.set("/tmp/a", { path: "/tmp/a", workspaces: [] } as ProjectConfig); projects.set("/tmp/b", { path: "/tmp/b", workspaces: [] } as ProjectConfig); diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index bd297ae332..949f03d505 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect } from "react"; -import type { ProjectConfig } from "@/node/config"; +import type { ProjectConfig } from "@/common/types/project"; import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { @@ -9,13 +9,16 @@ import { getProjectScopeId, getTrunkBranchKey, } from "@/common/constants/storage"; +import { + getFirstTopLevelProjectPath, + resolveWorkspaceCreationScope, +} from "@/common/utils/subProjects"; export type StartWorkspaceCreationDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION]; export function getFirstProjectPath(projects: Map): string | null { - const iterator = projects.keys().next(); - return iterator.done ? null : iterator.value; + return getFirstTopLevelProjectPath(projects); } type PersistFn = typeof updatePersistedState; @@ -78,8 +81,12 @@ export function useStartWorkspaceCreation({ return; } - persistWorkspaceCreationPrefill(resolvedProjectPath, detail); - beginWorkspaceCreation(resolvedProjectPath); + const creationScope = resolveWorkspaceCreationScope(resolvedProjectPath, projects); + // Sub-project creation shares the parent project's worktree/settings, so + // persisted prefill belongs to the owning parent even when the route opens + // the sub-project section. + persistWorkspaceCreationPrefill(creationScope.projectPath, detail); + beginWorkspaceCreation(creationScope.subProjectPath ?? creationScope.projectPath); }, [projects, beginWorkspaceCreation] ); diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 76afc0f402..ed408241c0 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -178,6 +178,35 @@ test("thinking effort command submits selected level", async () => { expect(onSetThinkingLevel).toHaveBeenCalledWith("w1", "high"); }); +test("selected-workspace create action targets the workspace sub-project", async () => { + const userProjects = new Map([ + ["/repo/a", { workspaces: [] }], + ["/repo/a/packages/api", { workspaces: [], parentProjectPath: "/repo/a", displayName: "API" }], + ]); + const workspaceMetadata = new Map([ + [ + "w1", + { + id: "w1", + name: "feat-x", + projectName: "a", + projectPath: "/repo/a", + subProjectPath: "/repo/a/packages/api", + namedWorkspacePath: "/repo/a/feat-x", + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + ], + ]); + const onStartWorkspaceCreation = mock(); + const sources = mk({ userProjects, workspaceMetadata, onStartWorkspaceCreation }); + const actions = sources.flatMap((s) => s()); + const createAction = actions.find((action) => action.id === "ws:new"); + + expect(createAction?.subtitle).toBe("for a / API"); + await createAction?.run(); + expect(onStartWorkspaceCreation).toHaveBeenCalledWith("/repo/a/packages/api"); +}); + test("buildCoreSources includes archive merged workspaces in project action", () => { const sources = mk(); const actions = sources.flatMap((s) => s()); @@ -253,6 +282,14 @@ test("project commands exclude system projects from options", async () => { workspaces: [{ path: "/repo/a/feat-x" }, { path: "/repo/a/feat-y" }], }, ], + [ + "/repo/a/packages/api", + { + workspaces: [], + parentProjectPath: "/repo/a", + displayName: "API", + }, + ], ["/repo/system", { workspaces: [], projectKind: "system" }], ]); @@ -272,7 +309,10 @@ test("project commands exclude system projects from options", async () => { } const createOptions = await createProjectField.getOptions({}); - expect(createOptions.map((option) => option.id)).toEqual(["/repo/a"]); + expect(createOptions.map((option) => option.id)).toEqual(["/repo/a", "/repo/a/packages/api"]); + expect(createOptions.find((option) => option.id === "/repo/a/packages/api")?.label).toBe( + "a / API" + ); expect(createOptions.some((option) => option.id === "/repo/system")).toBe(false); const archiveAction = actions.find((a) => a.title === "Archive Merged Workspaces in Project…"); @@ -284,6 +324,7 @@ test("project commands exclude system projects from options", async () => { } const archiveOptions = await archiveProjectField.getOptions({}); + expect(archiveOptions.some((option) => option.id === "/repo/a/packages/api")).toBe(false); expect(archiveOptions.map((option) => option.id)).toEqual(["/repo/a"]); expect(archiveOptions.some((option) => option.id === "/repo/system")).toBe(false); }); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 836fbfcdca..094f4dcbc5 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -20,7 +20,7 @@ import { getLayoutsConfigOrDefault, getPresetForSlot, } from "@/browser/utils/uiLayouts"; -import { formatProjectHierarchyLabel } from "@/common/utils/subProjects"; +import { formatProjectHierarchyLabel, getTopLevelProjectEntries } from "@/common/utils/subProjects"; import type { LayoutPresetsConfig, LayoutSlotNumber } from "@/common/types/uiLayouts"; import { addToolToFocusedTabset, @@ -238,13 +238,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const createWorkspaceForSelectedProjectAction = ( selected: NonNullable ): CommandAction => { + const metadata = p.workspaceMetadata.get(selected.workspaceId); + const targetProjectPath = metadata?.subProjectPath ?? selected.projectPath; + const targetProjectLabel = formatProjectHierarchyLabel(targetProjectPath, p.userProjects); return { id: CommandIds.workspaceNew(), title: "Create New Workspace…", - subtitle: `for ${selected.projectName}`, + subtitle: `for ${targetProjectLabel}`, section: section.workspaces, shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), - run: () => p.onStartWorkspaceCreation(selected.projectPath), + run: () => p.onStartWorkspaceCreation(targetProjectPath), }; }; @@ -984,13 +987,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi label: "Select project", placeholder: "Search projects…", getOptions: (_values) => - Array.from(p.userProjects.entries()) - .filter(([, projectConfig]) => !projectConfig.parentProjectPath) - .map(([projectPath]) => ({ - id: projectPath, - label: formatProjectHierarchyLabel(projectPath, p.userProjects), - keywords: [projectPath], - })), + getTopLevelProjectEntries(p.userProjects).map(([projectPath]) => ({ + id: projectPath, + label: formatProjectHierarchyLabel(projectPath, p.userProjects), + keywords: [projectPath], + })), }, ], onSubmit: async (vals) => { diff --git a/src/common/utils/subProjects.test.ts b/src/common/utils/subProjects.test.ts index 72e46110f0..18d269e2fb 100644 --- a/src/common/utils/subProjects.test.ts +++ b/src/common/utils/subProjects.test.ts @@ -3,8 +3,11 @@ import type { ProjectConfig } from "@/common/types/project"; import { deriveProjectHierarchy, formatProjectHierarchyLabel, + getFirstTopLevelProjectPath, getSubProjectsForParent, + getTopLevelProjectEntries, isPathDescendant, + resolveWorkspaceCreationScope, } from "./subProjects"; function project(overrides: Partial = {}): ProjectConfig { @@ -57,6 +60,39 @@ describe("subProjects", () => { expect(projects.get("/repo/packages/api/nested")?.parentProjectPath).toBe("/repo"); }); + test("returns top-level projects without sub-project entries", () => { + const projects = new Map([ + ["/repo/packages/api", project({ parentProjectPath: "/repo" })], + ["/repo", project()], + ["/other", project()], + ]); + + expect(getTopLevelProjectEntries(projects).map(([path]) => path)).toEqual(["/repo", "/other"]); + expect(getFirstTopLevelProjectPath(projects)).toBe("/repo"); + }); + + test("resolves workspace creation scope to the parent plus optional sub-project", () => { + const projects = new Map([ + ["/repo", project()], + ["/repo/packages/api", project({ parentProjectPath: "/repo" })], + ["/other", project()], + ["/other/packages/web", project({ parentProjectPath: "/other" })], + ]); + + expect(resolveWorkspaceCreationScope("/repo/packages/api", projects)).toEqual({ + projectPath: "/repo", + subProjectPath: "/repo/packages/api", + }); + expect(resolveWorkspaceCreationScope("/repo", projects, "/repo/packages/api")).toEqual({ + projectPath: "/repo", + subProjectPath: "/repo/packages/api", + }); + expect(resolveWorkspaceCreationScope("/repo", projects, "/other/packages/web")).toEqual({ + projectPath: "/repo", + subProjectPath: null, + }); + }); + test("orders sub-projects by display name and labels them with parent context", () => { const projects = deriveProjectHierarchy( new Map([ diff --git a/src/common/utils/subProjects.ts b/src/common/utils/subProjects.ts index dd574438d2..9448a30108 100644 --- a/src/common/utils/subProjects.ts +++ b/src/common/utils/subProjects.ts @@ -62,6 +62,47 @@ export function getTopLevelProjectPath( return projects.get(projectPath)?.parentProjectPath ?? projectPath; } +export function getTopLevelProjectEntries( + projects: Map +): Array<[string, ProjectConfig]> { + return Array.from(projects.entries()).filter( + ([, projectConfig]) => !projectConfig.parentProjectPath + ); +} + +export function getFirstTopLevelProjectPath(projects: Map): string | null { + return getTopLevelProjectEntries(projects)[0]?.[0] ?? null; +} + +export interface WorkspaceCreationScope { + projectPath: string; + subProjectPath: string | null; +} + +export function resolveWorkspaceCreationScope( + projectPath: string, + projects: Map, + subProjectPath?: string | null +): WorkspaceCreationScope { + const requestedProjectConfig = projects.get(projectPath); + const owningProjectPath = requestedProjectConfig?.parentProjectPath ?? projectPath; + const requestedSubProjectPath = requestedProjectConfig?.parentProjectPath + ? projectPath + : (subProjectPath ?? null); + const requestedSubProjectConfig = requestedSubProjectPath + ? projects.get(requestedSubProjectPath) + : undefined; + const normalizedSubProjectPath = + requestedSubProjectConfig?.parentProjectPath === owningProjectPath + ? requestedSubProjectPath + : null; + + return { + projectPath: owningProjectPath, + subProjectPath: normalizedSubProjectPath, + }; +} + export function getSubProjectsForParent( parentProjectPath: string, projects: Map diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index ba7ad9f69a..c3fb8aa8ec 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -1843,6 +1843,89 @@ exit 1 }); }); + describe("getFileCompletions", () => { + it("works for subdirectories inside a parent git repository", async () => { + const repoPath = await createLocalGitRepository(tempDir, "repo-with-sub-project"); + const subProjectPath = path.join(repoPath, "packages", "api"); + await fs.mkdir(subProjectPath, { recursive: true }); + await fs.writeFile(path.join(subProjectPath, "service.ts"), "export {};\n", "utf-8"); + + const result = await service.getFileCompletions(subProjectPath, "service"); + + expect(result.paths).toContain("service.ts"); + }); + }); + + describe("assignWorkspaceToSubProject", () => { + it("accepts either parent or sub-project path as the owner selector", async () => { + const parentPath = "/fake/project"; + const subProjectPath = "/fake/project/packages/api"; + const workspaceId = "workspace-1"; + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(parentPath, { + workspaces: [{ id: workspaceId, path: path.join(tempDir, "workspace-1") }], + }); + cfg.projects.set(subProjectPath, { + parentProjectPath: parentPath, + workspaces: [], + }); + await config.saveConfig(cfg); + + const result = await service.assignWorkspaceToSubProject( + subProjectPath, + workspaceId, + subProjectPath + ); + + expect(result.success).toBe(true); + const afterAssign = config.loadConfigOrDefault(); + expect(afterAssign.projects.get(parentPath)?.workspaces[0]?.subProjectPath).toBe( + subProjectPath + ); + + const clearResult = await service.assignWorkspaceToSubProject( + subProjectPath, + workspaceId, + null + ); + + expect(clearResult.success).toBe(true); + const afterClear = config.loadConfigOrDefault(); + expect(afterClear.projects.get(parentPath)?.workspaces[0]?.subProjectPath).toBeUndefined(); + }); + + it("rejects target sub-projects from a different parent", async () => { + const parentPath = "/fake/project"; + const subProjectPath = "/fake/project/packages/api"; + const otherSubProjectPath = "/other/project/packages/web"; + const workspaceId = "workspace-1"; + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(parentPath, { + workspaces: [{ id: workspaceId, path: path.join(tempDir, "workspace-1") }], + }); + cfg.projects.set(subProjectPath, { + parentProjectPath: parentPath, + workspaces: [], + }); + cfg.projects.set("/other/project", { workspaces: [] }); + cfg.projects.set(otherSubProjectPath, { + parentProjectPath: "/other/project", + workspaces: [], + }); + await config.saveConfig(cfg); + + const result = await service.assignWorkspaceToSubProject( + subProjectPath, + workspaceId, + otherSubProjectPath + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error("Expected failure"); + expect(result.error).toContain("Sub-project not found under parent"); + }); + }); + describe("remove", () => { it("removes project with no workspaces", async () => { const projectPath = "/fake/project"; diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 6a6f3c0bb4..521657cc24 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -1249,7 +1249,10 @@ export class ProjectService { if (isStale && !cacheEntry.refreshing) { cacheEntry.refreshing = (async () => { try { - if (!(await isGitRepository(normalizedPath))) { + // Sub-projects share a parent git repository; match branch listing by + // accepting any path inside a work tree rather than requiring `.git` + // directly under the project directory. + if (!(await isInsideGitRepository(normalizedPath))) { cacheEntry.index = EMPTY_FILE_COMPLETIONS_INDEX; return; } @@ -1394,20 +1397,28 @@ export class ProjectService { ): Promise> { try { const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); + const requestedProject = config.projects.get(projectPath); - if (!project) { + if (!requestedProject) { return Err(`Project not found: ${projectPath}`); } + // Match workspace creation: callers may identify the current sub-project, + // but the workspace row itself is stored in the parent project's bucket. + const owningProjectPath = requestedProject.parentProjectPath ?? projectPath; + const owningProject = config.projects.get(owningProjectPath); + if (!owningProject) { + return Err(`Project not found: ${owningProjectPath}`); + } + if (subProjectPath !== null) { const subProject = config.projects.get(subProjectPath); - if (subProject?.parentProjectPath !== projectPath) { + if (subProject?.parentProjectPath !== owningProjectPath) { return Err(`Sub-project not found under parent: ${subProjectPath}`); } } - const workspace = project.workspaces.find((w) => w.id === workspaceId); + const workspace = owningProject.workspaces.find((w) => w.id === workspaceId); if (!workspace) { return Err(`Workspace not found: ${workspaceId}`); } From 92e8093da5a76f8f9027e2aa51ed077821381efa Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 18:13:10 -0500 Subject: [PATCH 2/6] Trim sub-project scope helpers --- .../ProjectSidebar/ProjectSidebar.tsx | 32 +++++------ src/browser/contexts/ProjectContext.tsx | 12 ++--- src/browser/contexts/WorkspaceContext.tsx | 26 +++------ .../Settings/Sections/SecretsSection.tsx | 28 +++++++--- .../hooks/useStartWorkspaceCreation.test.ts | 21 -------- .../hooks/useStartWorkspaceCreation.ts | 6 +-- src/common/utils/subProjects.ts | 53 ++++++++----------- 7 files changed, 68 insertions(+), 110 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index bdba118248..f3b40b8ee4 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -308,7 +308,7 @@ type DraggableProjectItemProps = React.PropsWithChildren<{ "data-project-path"?: string; }>; -const DraggableProjectItemBase: React.FC = ({ +const DraggableProjectItem: React.FC = ({ projectPath, onReorder, children, @@ -357,7 +357,6 @@ const DraggableProjectItemBase: React.FC = ({ ); }; -const DraggableProjectItem = DraggableProjectItemBase; /** * Wrapper that fetches draft data from localStorage and renders via unified AgentListItem. * Keeps data-fetching logic colocated with sidebar while delegating rendering to shared component. @@ -470,8 +469,6 @@ interface ProjectDragItem { type: "PROJECT"; projectPath: string; } -type DragItem = ProjectDragItem | null; - const ProjectDragLayer: React.FC = () => { const dragState = useDragLayer<{ isDragging: boolean; @@ -483,7 +480,7 @@ const ProjectDragLayer: React.FC = () => { currentOffset: monitor.getClientOffset(), })); const isDragging = dragState.isDragging; - const item = dragState.item as DragItem; + const item = dragState.item as ProjectDragItem | null; const currentOffset = dragState.currentOffset; React.useEffect(() => { @@ -498,7 +495,6 @@ const ProjectDragLayer: React.FC = () => { }; }, [isDragging]); - // Only render for PROJECT type drags (not section reorder) if (!isDragging || !currentOffset || !item?.projectPath || item.type !== "PROJECT") return null; const abbrevPath = PlatformPaths.abbreviate(item.projectPath); @@ -2032,8 +2028,6 @@ const ProjectSidebarInner: React.FC = ({ {(() => { // Archived workspaces are excluded from workspaceMetadata so won't appear here - const allWorkspaces = projectWorkspaces; - const draftsForProject = workspaceDraftsByProject[projectPath] ?? []; const activeDraftIds = new Set( draftsForProject.map((draft) => draft.draftId) @@ -2048,7 +2042,7 @@ const ProjectSidebarInner: React.FC = ({ const promotedWorkspaceIds = new Set( Object.values(activeDraftPromotions).map((metadata) => metadata.id) ); - const workspacesForNormalRendering = allWorkspaces.filter( + const workspacesForNormalRendering = projectWorkspaces.filter( (workspace) => !promotedWorkspaceIds.has(workspace.id) ); const sections: SectionConfig[] = getSubProjectsForParent( @@ -2059,7 +2053,8 @@ const ProjectSidebarInner: React.FC = ({ name: getProjectDisplayName(subProjectPath, subProjectConfig), color: subProjectConfig.color, })); - const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); + const depthByWorkspaceId = + computeWorkspaceDepthMap(projectWorkspaces); const visibleWorkspacesForNormalRendering = filterVisibleAgentRows( workspacesForNormalRendering, expandedCompletedParentIds @@ -2087,22 +2082,21 @@ const ProjectSidebarInner: React.FC = ({ (draft, index) => [draft.draftId, index + 1] as const ) ); - const sectionIds = new Set(sections.map((section) => section.id)); - const normalizeDraftSectionId = ( + const getDraftSectionId = ( draft: (typeof sortedDrafts)[number] - ): string | null => { - return typeof draft.subProjectPath === "string" && - sectionIds.has(draft.subProjectPath) + ): string | null => + typeof draft.subProjectPath === "string" && + userProjects.get(draft.subProjectPath)?.parentProjectPath === + projectPath ? draft.subProjectPath : null; - }; // Drafts can reference a section that has since been deleted. // Treat those as unsectioned so they remain accessible. const unsectionedDrafts: typeof sortedDrafts = []; const draftsBySectionId = new Map(); for (const draft of sortedDrafts) { - const sectionId = normalizeDraftSectionId(draft); + const sectionId = getDraftSectionId(draft); if (sectionId === null) { unsectionedDrafts.push(draft); continue; @@ -2387,12 +2381,12 @@ const ProjectSidebarInner: React.FC = ({ const renderDraft = ( draft: (typeof sortedDrafts)[number] ): React.ReactNode => { - const sectionId = normalizeDraftSectionId(draft); + const sectionId = getDraftSectionId(draft); const promotedMetadata = activeDraftPromotions[draft.draftId]; if (promotedMetadata) { const liveMetadata = - allWorkspaces.find( + projectWorkspaces.find( (workspace) => workspace.id === promotedMetadata.id ) ?? promotedMetadata; return renderWorkspace(liveMetadata, sectionId ?? undefined); diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index 8e0d342f69..0d09df08cd 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -312,12 +312,6 @@ export function ProjectProvider(props: { children: ReactNode }) { const hasAnyProject = allProjectsInternal.size > 0; - // Default project selection should target parent-owned projects. Sub-projects - // are displayed as sections under their parent rather than standalone routes. - const resolveDefaultProjectPath = useCallback(() => { - return getFirstTopLevelProjectPath(userProjects); - }, [userProjects]); - // Canonical resolver for new-chat deep links: explicit selectors first, default fallback last. const resolveNewChatProjectPath = useCallback( (selector: NewChatProjectSelector): string | null => { @@ -341,9 +335,11 @@ export function ProjectProvider(props: { children: ReactNode }) { if (byQuery) return byQuery; } - return resolveDefaultProjectPath(); + // Default project selection targets parent-owned projects because sub-projects + // are displayed as sections under their parent rather than standalone routes. + return getFirstTopLevelProjectPath(userProjects); }, - [resolveProjectPath, resolveDefaultProjectPath] + [resolveProjectPath, userProjects] ); const getBranchesForProject = useCallback( diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e156fab327..ac82319ce0 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -69,9 +69,9 @@ import { useRouter } from "@/browser/contexts/RouterContext"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; import { normalizeAgentId } from "@/common/utils/agentIds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { resolveWorkspaceCreationScope } from "@/common/utils/subProjects"; import type { APIClient } from "@/browser/contexts/API"; import { getErrorMessage } from "@/common/utils/errors"; +import type { WorkspaceCreationScope } from "@/common/utils/subProjects"; /** * One-time best-effort migration: if the backend doesn't have model preferences yet, @@ -535,16 +535,11 @@ function shouldBlockStartupAutoNavigation(options: { ); } -interface WorkspaceRouteScope { - projectPath: string; - subProjectPath: string | null; -} - function getMostRecentVisibleWorkspaceScope( workspaceMetadata: Map, workspaceRecency: Record, getProjectConfig: (projectPath: string) => { projectKind?: "user" | "system" } | undefined -): WorkspaceRouteScope | null { +): WorkspaceCreationScope | null { const recentWorkspace = [...workspaceMetadata.values()] .filter((workspace) => { const projectConfig = getProjectConfig(workspace.projectPath); @@ -730,17 +725,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { } const resolvedProjectConfig = getProjectConfig(resolvedProjectPath); - const projectsForScope = new Map>(); - if (resolvedProjectConfig) { - projectsForScope.set(resolvedProjectPath, resolvedProjectConfig); - } - const creationScope = resolveWorkspaceCreationScope( - resolvedProjectPath, - projectsForScope, - null - ); - const owningProjectPath = creationScope.projectPath; - const normalizedSubProjectPath = creationScope.subProjectPath; + const owningProjectPath = resolvedProjectConfig?.parentProjectPath ?? resolvedProjectPath; + const normalizedSubProjectPath = resolvedProjectConfig?.parentProjectPath + ? resolvedProjectPath + : null; // IMPORTANT: Deep links should always create a fresh draft, even if an existing draft // is empty. This keeps deep-link navigations predictable and avoids surprising reuse. @@ -916,7 +904,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { isAnalyticsOpen || pendingNewWorkspaceProject != null; - const resolveFallbackWorkspaceScope = useCallback((): WorkspaceRouteScope | null => { + const resolveFallbackWorkspaceScope = useCallback((): WorkspaceCreationScope | null => { const recentScope = getMostRecentVisibleWorkspaceScope( workspaceMetadata, workspaceStore.getWorkspaceRecency(), diff --git a/src/browser/features/Settings/Sections/SecretsSection.tsx b/src/browser/features/Settings/Sections/SecretsSection.tsx index 3eb1831ec1..c6f77e30c7 100644 --- a/src/browser/features/Settings/Sections/SecretsSection.tsx +++ b/src/browser/features/Settings/Sections/SecretsSection.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { KeyRound, Loader2, Trash2 } from "lucide-react"; +import type { ProjectConfig } from "@/common/types/project"; import { isOpSecretValue, type Secret } from "@/common/types/secrets"; import { useAPI } from "@/browser/contexts/API"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; @@ -20,7 +21,11 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/SelectPrimitive/SelectPrimitive"; -import { formatProjectHierarchyLabel, getTopLevelProjectEntries } from "@/common/utils/subProjects"; +import { + formatProjectHierarchyLabel, + getFirstTopLevelProjectPath, + getTopLevelProjectEntries, +} from "@/common/utils/subProjects"; type SecretsScope = "global" | "project"; @@ -117,6 +122,13 @@ function secretsEqual(a: Secret[], b: Secret[]): boolean { return true; } +function isProjectSecretsTarget( + userProjects: Map, + projectPath: string +): boolean { + return userProjects.get(projectPath)?.parentProjectPath == null; +} + export const SecretsSection: React.FC = () => { const { api } = useAPI(); const { userProjects } = useProjectContext(); @@ -127,7 +139,9 @@ export const SecretsSection: React.FC = () => { // are applied from the parent workspace owner, so sub-projects stay out of the // project picker until runtime injection supports child-specific secrets. const initialScope: SecretsScope = - secretsProjectPath && projectList.includes(secretsProjectPath) ? "project" : "global"; + secretsProjectPath && isProjectSecretsTarget(userProjects, secretsProjectPath) + ? "project" + : "global"; const initialProject = initialScope === "project" ? secretsProjectPath! : ""; const [scope, setScope] = useState(initialScope); @@ -172,11 +186,11 @@ export const SecretsSection: React.FC = () => { // projects load asynchronously, so we must keep the hint alive until then. useEffect(() => { if (!secretsProjectPath) return; - if (!projectList.includes(secretsProjectPath)) return; + if (!isProjectSecretsTarget(userProjects, secretsProjectPath)) return; setScope("project"); setSelectedProject(secretsProjectPath); setSecretsProjectPath(null); - }, [secretsProjectPath, projectList, setSecretsProjectPath]); + }, [secretsProjectPath, userProjects, setSecretsProjectPath]); // Default to the first project when switching into Project scope. useEffect(() => { @@ -184,12 +198,12 @@ export const SecretsSection: React.FC = () => { return; } - if (selectedProject && projectList.includes(selectedProject)) { + if (selectedProject && isProjectSecretsTarget(userProjects, selectedProject)) { return; } - setSelectedProject(projectList[0] ?? ""); - }, [projectList, scope, selectedProject]); + setSelectedProject(getFirstTopLevelProjectPath(userProjects) ?? ""); + }, [scope, selectedProject, userProjects]); const currentProjectPath = scope === "project" ? selectedProject : undefined; diff --git a/src/browser/hooks/useStartWorkspaceCreation.test.ts b/src/browser/hooks/useStartWorkspaceCreation.test.ts index 0d7e2ae4d9..0daf0698c8 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.test.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { - getFirstProjectPath, persistWorkspaceCreationPrefill, type StartWorkspaceCreationDetail, } from "./useStartWorkspaceCreation"; @@ -11,8 +10,6 @@ import { getProjectScopeId, getTrunkBranchKey, } from "@/common/constants/storage"; -import type { ProjectConfig } from "@/common/types/project"; - import type { updatePersistedState } from "@/browser/hooks/usePersistedState"; type PersistFn = typeof updatePersistedState; @@ -77,21 +74,3 @@ describe("persistWorkspaceCreationPrefill", () => { expect(calls).toHaveLength(0); }); }); - -describe("getFirstProjectPath", () => { - test("returns first top-level project path or null", () => { - const emptyProjects = new Map(); - expect(getFirstProjectPath(emptyProjects)).toBeNull(); - - const projects = new Map(); - projects.set("/tmp/a/packages/api", { - path: "/tmp/a/packages/api", - parentProjectPath: "/tmp/a", - workspaces: [], - } as ProjectConfig); - projects.set("/tmp/a", { path: "/tmp/a", workspaces: [] } as ProjectConfig); - projects.set("/tmp/b", { path: "/tmp/b", workspaces: [] } as ProjectConfig); - - expect(getFirstProjectPath(projects)).toBe("/tmp/a"); - }); -}); diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index 949f03d505..cce35f95d1 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -17,10 +17,6 @@ import { export type StartWorkspaceCreationDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION]; -export function getFirstProjectPath(projects: Map): string | null { - return getFirstTopLevelProjectPath(projects); -} - type PersistFn = typeof updatePersistedState; export function persistWorkspaceCreationPrefill( @@ -65,7 +61,7 @@ function resolveProjectPath( return requestedPath; } - return getFirstProjectPath(projects); + return getFirstTopLevelProjectPath(projects); } export function useStartWorkspaceCreation({ diff --git a/src/common/utils/subProjects.ts b/src/common/utils/subProjects.ts index 9448a30108..fbb557a17b 100644 --- a/src/common/utils/subProjects.ts +++ b/src/common/utils/subProjects.ts @@ -16,23 +16,22 @@ export function isPathDescendant(parentPath: string, candidatePath: string): boo return candidate.startsWith(`${parent}/`) && candidate.length > parent.length + 1; } -export function getDirectParentProjectPath( +function getTopLevelAncestorProjectPath( projectPath: string, - projects: Map + projectPaths: Iterable ): string | null { - const ancestorPaths = Array.from(projects.keys()).filter( - (candidatePath) => candidatePath !== projectPath && isPathDescendant(candidatePath, projectPath) - ); + let topLevelAncestorPath: string | null = null; + for (const candidatePath of projectPaths) { + if (candidatePath === projectPath || !isPathDescendant(candidatePath, projectPath)) { + continue; + } - const topLevelAncestors = ancestorPaths.filter( - (ancestorPath) => - !ancestorPaths.some( - (otherAncestorPath) => - otherAncestorPath !== ancestorPath && isPathDescendant(otherAncestorPath, ancestorPath) - ) - ); + if (topLevelAncestorPath === null || candidatePath.length < topLevelAncestorPath.length) { + topLevelAncestorPath = candidatePath; + } + } - return topLevelAncestors.sort((left, right) => right.length - left.length)[0] ?? null; + return topLevelAncestorPath; } export function deriveProjectHierarchy( @@ -40,28 +39,15 @@ export function deriveProjectHierarchy( ): Map { const next = new Map(); for (const [projectPath, projectConfig] of projects) { - next.set(projectPath, { ...projectConfig, parentProjectPath: undefined }); - } - - for (const [projectPath, projectConfig] of next) { - const parentProjectPath = getDirectParentProjectPath(projectPath, next); - if (!parentProjectPath) { - next.set(projectPath, { ...projectConfig, parentProjectPath: undefined }); - continue; - } - next.set(projectPath, { ...projectConfig, parentProjectPath }); + next.set(projectPath, { + ...projectConfig, + parentProjectPath: getTopLevelAncestorProjectPath(projectPath, projects.keys()) ?? undefined, + }); } return next; } -export function getTopLevelProjectPath( - projectPath: string, - projects: Map -): string { - return projects.get(projectPath)?.parentProjectPath ?? projectPath; -} - export function getTopLevelProjectEntries( projects: Map ): Array<[string, ProjectConfig]> { @@ -71,7 +57,12 @@ export function getTopLevelProjectEntries( } export function getFirstTopLevelProjectPath(projects: Map): string | null { - return getTopLevelProjectEntries(projects)[0]?.[0] ?? null; + for (const [projectPath, projectConfig] of projects) { + if (!projectConfig.parentProjectPath) { + return projectPath; + } + } + return null; } export interface WorkspaceCreationScope { From 897f0dcd8a976806908564621f34f1a0b6467cc0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 18:20:50 -0500 Subject: [PATCH 3/6] Drain sub-project scope cleanup --- src/browser/features/ChatInput/index.tsx | 13 ++++++------- src/browser/hooks/useStartWorkspaceCreation.ts | 15 +++------------ src/common/utils/subProjects.ts | 2 +- src/node/services/projectService.test.ts | 8 +------- 4 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d304543cf9..fadf1f892f 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -211,7 +211,6 @@ const ChatInputInner: React.FC = (props) => { const creationSubProjectPath = creationScope?.subProjectPath ?? undefined; const creationProject = variant === "creation" ? userProjects.get(creationParentProjectPath) : undefined; - const creationProjectPath = creationParentProjectPath; const [thinkingLevel] = useThinkingLevel(); const atMentionProjectPath = variant === "creation" ? props.projectPath : null; const workspaceId = variant === "workspace" ? props.workspaceId : null; @@ -612,7 +611,7 @@ const ChatInputInner: React.FC = (props) => { // Get current send message options from shared hook (must be at component top level) // For creation variant, use project-scoped key; for workspace, use workspace ID const sendMessageOptions = useSendMessageOptions( - variant === "workspace" ? props.workspaceId : getProjectScopeId(creationProjectPath) + variant === "workspace" ? props.workspaceId : getProjectScopeId(creationParentProjectPath) ); // Extract models for convenience (don't create separate state - use hook as single source of truth) // - preferredModel: selected model used for backend routing, preserving explicit gateway choices @@ -670,7 +669,7 @@ const ChatInputInner: React.FC = (props) => { onModelChange(selectedModel); } else { const scopeId = - variant === "creation" ? getProjectScopeId(creationProjectPath) : workspaceId; + variant === "creation" ? getProjectScopeId(creationParentProjectPath) : workspaceId; if (scopeId) { setWorkspaceModelWithOrigin(scopeId, selectedModel, "user"); } @@ -724,7 +723,7 @@ const ChatInputInner: React.FC = (props) => { [ api, agentId, - creationProjectPath, + creationParentProjectPath, ensureModelInSettings, onModelChange, thinkingLevel, @@ -842,7 +841,7 @@ const ChatInputInner: React.FC = (props) => { onSelectedRuntimeChange: creationState.setSelectedRuntime, onSetDefaultRuntime: creationState.setDefaultRuntimeChoice, disabled: isSendInFlight, - projectPath: creationParentProjectPath || props.projectPath, + projectPath: creationParentProjectPath, // Surface the actually-targeted project (possibly a sub-project) to // the dropdown so the trigger label reflects what the user picked, // while runtime/settings scoping stays on the parent above. When @@ -985,7 +984,7 @@ const ChatInputInner: React.FC = (props) => { return; } - const scopeId = getProjectScopeId(creationProjectPath); + const scopeId = getProjectScopeId(creationParentProjectPath); const modelKey = getModelKey(scopeId); const thinkingKey = getThinkingLevelKey(scopeId); @@ -1021,7 +1020,7 @@ const ChatInputInner: React.FC = (props) => { if (existingThinking !== resolvedThinking) { updatePersistedState(thinkingKey, resolvedThinking); } - }, [agentAiDefaults, agentId, creationProjectPath, defaultModel, variant]); + }, [agentAiDefaults, agentId, creationParentProjectPath, defaultModel, variant]); // Expose ChatInput auto-focus completion for Storybook/tests. const chatInputSectionRef = useRef(null); diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index cce35f95d1..8ab2c309b8 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -53,24 +53,15 @@ interface UseStartWorkspaceCreationOptions { beginWorkspaceCreation: (projectPath: string) => void; } -function resolveProjectPath( - projects: Map, - requestedPath: string -): string | null { - if (projects.has(requestedPath)) { - return requestedPath; - } - - return getFirstTopLevelProjectPath(projects); -} - export function useStartWorkspaceCreation({ projects, beginWorkspaceCreation, }: UseStartWorkspaceCreationOptions) { const startWorkspaceCreation = useCallback( (projectPath: string, detail?: StartWorkspaceCreationDetail) => { - const resolvedProjectPath = resolveProjectPath(projects, projectPath); + const resolvedProjectPath = projects.has(projectPath) + ? projectPath + : getFirstTopLevelProjectPath(projects); if (!resolvedProjectPath) { console.warn("No projects available for workspace creation"); diff --git a/src/common/utils/subProjects.ts b/src/common/utils/subProjects.ts index fbb557a17b..57af5390e6 100644 --- a/src/common/utils/subProjects.ts +++ b/src/common/utils/subProjects.ts @@ -22,7 +22,7 @@ function getTopLevelAncestorProjectPath( ): string | null { let topLevelAncestorPath: string | null = null; for (const candidatePath of projectPaths) { - if (candidatePath === projectPath || !isPathDescendant(candidatePath, projectPath)) { + if (!isPathDescendant(candidatePath, projectPath)) { continue; } diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index c3fb8aa8ec..75c2794994 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -1896,18 +1896,12 @@ exit 1 it("rejects target sub-projects from a different parent", async () => { const parentPath = "/fake/project"; - const subProjectPath = "/fake/project/packages/api"; const otherSubProjectPath = "/other/project/packages/web"; const workspaceId = "workspace-1"; const cfg = config.loadConfigOrDefault(); cfg.projects.set(parentPath, { workspaces: [{ id: workspaceId, path: path.join(tempDir, "workspace-1") }], }); - cfg.projects.set(subProjectPath, { - parentProjectPath: parentPath, - workspaces: [], - }); - cfg.projects.set("/other/project", { workspaces: [] }); cfg.projects.set(otherSubProjectPath, { parentProjectPath: "/other/project", workspaces: [], @@ -1915,7 +1909,7 @@ exit 1 await config.saveConfig(cfg); const result = await service.assignWorkspaceToSubProject( - subProjectPath, + parentPath, workspaceId, otherSubProjectPath ); From 28b128625d671b150f648db58e068a6628dccc8c Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 18:27:48 -0500 Subject: [PATCH 4/6] Align new workspace shortcut with sub-project target --- .../components/ProjectSidebar/ProjectSidebar.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index f3b40b8ee4..9bacf42929 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1668,10 +1668,14 @@ const ProjectSidebarInner: React.FC = ({ // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Create new workspace for the project of the selected workspace + // Sub-project workspaces share the parent worktree, so recover the child + // target from workspace metadata before opening a sibling draft. if (matchesKeybind(e, KEYBINDS.NEW_WORKSPACE) && selectedWorkspace) { e.preventDefault(); - handleAddWorkspace(selectedWorkspace.projectPath); + const subProjectPath = workspaceStore.getWorkspaceMetadata( + selectedWorkspace.workspaceId + )?.subProjectPath; + handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); } else if (matchesKeybind(e, KEYBINDS.ARCHIVE_WORKSPACE) && selectedWorkspace) { e.preventDefault(); void handleArchiveWorkspace(selectedWorkspace.workspaceId); @@ -1680,7 +1684,7 @@ const ProjectSidebarInner: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace]); + }, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace, workspaceStore]); return ( From 66fa1eddf8c5813dde10b496fe698e1af1af473a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 18:34:36 -0500 Subject: [PATCH 5/6] Use live metadata for new workspace shortcut --- .../components/ProjectSidebar/ProjectSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 9bacf42929..19519d8692 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1669,12 +1669,12 @@ const ProjectSidebarInner: React.FC = ({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Sub-project workspaces share the parent worktree, so recover the child - // target from workspace metadata before opening a sibling draft. + // target from the live sidebar metadata before opening a sibling draft. if (matchesKeybind(e, KEYBINDS.NEW_WORKSPACE) && selectedWorkspace) { e.preventDefault(); - const subProjectPath = workspaceStore.getWorkspaceMetadata( - selectedWorkspace.workspaceId - )?.subProjectPath; + const subProjectPath = sortedWorkspacesByProject + .get(selectedWorkspace.projectPath) + ?.find((workspace) => workspace.id === selectedWorkspace.workspaceId)?.subProjectPath; handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); } else if (matchesKeybind(e, KEYBINDS.ARCHIVE_WORKSPACE) && selectedWorkspace) { e.preventDefault(); @@ -1684,7 +1684,7 @@ const ProjectSidebarInner: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace, workspaceStore]); + }, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace, sortedWorkspacesByProject]); return ( From 728cb856ab4b4e2329022b8314e2162571948bf8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 10 May 2026 18:41:38 -0500 Subject: [PATCH 6/6] Require live project for secrets target --- src/browser/features/Settings/Sections/SecretsSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/features/Settings/Sections/SecretsSection.tsx b/src/browser/features/Settings/Sections/SecretsSection.tsx index c6f77e30c7..3b830684ed 100644 --- a/src/browser/features/Settings/Sections/SecretsSection.tsx +++ b/src/browser/features/Settings/Sections/SecretsSection.tsx @@ -126,7 +126,8 @@ function isProjectSecretsTarget( userProjects: Map, projectPath: string ): boolean { - return userProjects.get(projectPath)?.parentProjectPath == null; + const project = userProjects.get(projectPath); + return project !== undefined && project.parentProjectPath == null; } export const SecretsSection: React.FC = () => {