From ba1fdba5bbdcb7ea96fc0a094caf1c22286bcb1a Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 6 Apr 2026 17:54:50 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20replace=20sectio?= =?UTF-8?q?ns=20with=20sub-project=20hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace project sections with path-inferred sub-project nesting, drop section-specific creation and routing state, and layer AGENTS instructions from parent to child directories. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `n/a`_ --- src/browser/App.tsx | 2 - .../components/ProjectPage/ProjectPage.tsx | 4 - .../ProjectSidebar/ProjectSidebar.test.tsx | 238 +----- .../ProjectSidebar/ProjectSidebar.tsx | 648 ++++++--------- src/browser/contexts/ProjectContext.tsx | 104 +-- src/browser/contexts/RouterContext.tsx | 17 +- src/browser/contexts/WorkspaceContext.tsx | 122 +-- .../features/ChatInput/CreationControls.tsx | 144 +--- src/browser/features/ChatInput/index.tsx | 67 +- src/browser/features/ChatInput/types.ts | 2 - .../ChatInput/useCreationWorkspace.ts | 5 - .../stories/App.phoneViewports.stories.tsx | 55 +- .../utils/ui/workspaceFiltering.test.ts | 2 +- src/browser/utils/ui/workspaceFiltering.ts | 20 +- src/browser/utils/workspace.ts | 1 - src/common/orpc/schemas/api.ts | 49 +- src/common/orpc/schemas/workspace.ts | 3 - src/common/schemas/project.ts | 6 - src/node/config.ts | 6 - src/node/orpc/router.ts | 51 +- src/node/services/projectService.ts | 209 ----- src/node/services/systemMessage.test.ts | 67 ++ src/node/services/systemMessage.ts | 114 ++- src/node/services/workspaceService.ts | 6 +- tests/ui/chat/sections.test.ts | 774 ------------------ 25 files changed, 483 insertions(+), 2233 deletions(-) delete mode 100644 tests/ui/chat/sections.test.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 9aad7e5038..97911a9bd9 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -113,7 +113,6 @@ function AppInner() { selectedWorkspace, setSelectedWorkspace, pendingNewWorkspaceProject, - pendingNewWorkspaceSectionId, pendingNewWorkspaceDraftId, beginWorkspaceCreation, } = useWorkspaceContext(); @@ -1116,7 +1115,6 @@ function AppInner() { projectName={projectName} leftSidebarCollapsed={sidebarCollapsed} onToggleLeftSidebarCollapsed={handleToggleSidebar} - pendingSectionId={pendingNewWorkspaceSectionId} pendingDraftId={pendingNewWorkspaceDraftId} onWorkspaceCreated={(metadata, options) => { // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure diff --git a/src/browser/components/ProjectPage/ProjectPage.tsx b/src/browser/components/ProjectPage/ProjectPage.tsx index bf02d41a9e..b51cf05fd4 100644 --- a/src/browser/components/ProjectPage/ProjectPage.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.tsx @@ -41,8 +41,6 @@ interface ProjectPageProps { onToggleLeftSidebarCollapsed: () => void; /** Draft ID for UI-only workspace creation drafts (from URL) */ pendingDraftId?: string | null; - /** Section ID to pre-select when creating (from sidebar section "+" button) */ - pendingSectionId?: string | null; onWorkspaceCreated: ( metadata: FrontendWorkspaceMetadata, options?: WorkspaceCreatedOptions @@ -75,7 +73,6 @@ export const ProjectPage: React.FC = ({ leftSidebarCollapsed, onToggleLeftSidebarCollapsed, pendingDraftId, - pendingSectionId, onWorkspaceCreated, }) => { const { api } = useAPI(); @@ -327,7 +324,6 @@ export const ProjectPage: React.FC = ({ variant="creation" projectPath={projectPath} projectName={projectName} - pendingSectionId={pendingSectionId} pendingDraftId={pendingDraftId} onReady={handleChatReady} onWorkspaceCreated={onWorkspaceCreated} diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index e0847ac919..e1702d86f8 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -132,7 +132,7 @@ let archivePopoverShowErrorMock = mock( ); function createProjectContextValue( - overrides: Partial = {} + overrides: Partial & Record = {} ): ProjectContextModule.ProjectContext { return { userProjects: new Map(), @@ -162,12 +162,6 @@ function createProjectContextValue( updateSecrets: () => Promise.resolve(), updateDisplayName: () => resolveVoidResult(), updateColor: () => resolveVoidResult(), - createSection: () => - Promise.resolve({ success: true, data: { id: "section-1", name: "Section" } }), - updateSection: () => resolveVoidResult(), - removeSection: () => resolveVoidResult(), - reorderSections: () => resolveVoidResult(), - assignWorkspaceToSection: () => resolveVoidResult(), hasAnyProject: false, resolveNewChatProjectPath: () => null, ...overrides, @@ -719,12 +713,6 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { updateSecrets: () => Promise.resolve(), updateDisplayName: () => resolveVoidResult(), updateColor: () => resolveVoidResult(), - createSection: () => - Promise.resolve({ success: true, data: { id: "section-1", name: "Section" } }), - updateSection: () => resolveVoidResult(), - removeSection: () => resolveVoidResult(), - reorderSections: () => resolveVoidResult(), - assignWorkspaceToSection: () => resolveVoidResult(), hasAnyProject: true, resolveNewChatProjectPath: () => "/projects/demo-project", })); @@ -830,12 +818,6 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { updateSecrets: () => Promise.resolve(), updateDisplayName: () => resolveVoidResult(), updateColor: () => resolveVoidResult(), - createSection: () => - Promise.resolve({ success: true, data: { id: "section-1", name: "Section" } }), - updateSection: () => resolveVoidResult(), - removeSection: () => resolveVoidResult(), - reorderSections: () => resolveVoidResult(), - assignWorkspaceToSection: () => resolveVoidResult(), hasAnyProject: true, resolveNewChatProjectPath: () => "/projects/demo-project", })); @@ -939,12 +921,6 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { updateSecrets: () => Promise.resolve(), updateDisplayName: () => resolveVoidResult(), updateColor: () => resolveVoidResult(), - createSection: () => - Promise.resolve({ success: true, data: { id: "section-1", name: "Section" } }), - updateSection: () => resolveVoidResult(), - removeSection: () => resolveVoidResult(), - reorderSections: () => resolveVoidResult(), - assignWorkspaceToSection: () => resolveVoidResult(), hasAnyProject: true, resolveNewChatProjectPath: () => "/projects/demo-project", })); @@ -1456,7 +1432,6 @@ describe("ProjectSidebar project actions menu", () => { const menuButtons = within(menu).getAllByRole("button"); expect(menuButtons.map((button) => button.textContent)).toEqual([ "Edit name", - "Add sub-folder", "Manage secrets", "Change color", "Delete...", @@ -1472,6 +1447,28 @@ describe("ProjectSidebar project actions menu", () => { expect(view.getByRole("button", { name: "Edit name" })).toBeTruthy(); }); + test("shows nested sub-project rows only when the parent project is expanded", () => { + const childProjectPath = `${demoProjectPath}/packages/payments`; + window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([])); + projectContextValue = createProjectContextValue({ + userProjects: new Map([ + [demoProjectPath, { workspaces: [] }], + [ + childProjectPath, + { workspaces: [{ path: `${childProjectPath}/ws-1` }], displayName: "payments" }, + ], + ]), + }); + + const view = renderSidebar(); + + expect(view.queryByText("payments")).toBeNull(); + + fireEvent.click(view.getByRole("button", { name: "Expand project demo-project" })); + + expect(view.getByText("payments")).toBeTruthy(); + }); + test("menu actions route to settings and delete confirmation", () => { projectContextValue = createProjectContextValue({ userProjects: new Map([ @@ -1598,193 +1595,6 @@ describe("ProjectSidebar project actions menu", () => { expect(inputAfterRefresh?.value).toBe("#123456"); }); - test("Add sub-folder expands collapsed project before auto-editing", async () => { - window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([])); - const createSection = mock(() => - Promise.resolve({ - success: true as const, - data: { id: "new-section", name: "New sub-folder", color: "#6B7280", nextId: null }, - }) - ); - projectContextValue = createProjectContextValue({ - userProjects: new Map([ - [ - demoProjectPath, - { - workspaces: [], - sections: [ - { id: "new-section", name: "New sub-folder", color: "#6B7280", nextId: null }, - ], - }, - ], - ]), - createSection, - }); - - const view = renderSidebar(); - expect(view.getByRole("button", { name: "Expand project demo-project" })).toBeTruthy(); - - fireEvent.click(view.getByRole("button", { name: "Project options for demo-project" })); - fireEvent.click(view.getByRole("button", { name: "Add sub-folder" })); - - await waitFor(() => { - expect(createSection).toHaveBeenCalledWith(demoProjectPath, "New sub-folder"); - expect(view.getByRole("button", { name: "Collapse project demo-project" })).toBeTruthy(); - }); - }); - - test("Add sub-folder abandon reuses section-delete confirmation before removing", async () => { - const createSection = mock(() => - Promise.resolve({ - success: true as const, - data: { id: "new-section", name: "New sub-folder", color: "#6B7280", nextId: null }, - }) - ); - const removeSection = mock(() => resolveVoidResult()); - projectContextValue = createProjectContextValue({ - userProjects: new Map([ - [ - demoProjectPath, - { - workspaces: [ - { - path: `${demoProjectPath}/ws-in-section`, - sectionId: "new-section", - }, - ], - sections: [ - { id: "new-section", name: "New sub-folder", color: "#6B7280", nextId: null }, - ], - }, - ], - ]), - createSection, - removeSection, - }); - - const view = renderSidebar(); - - fireEvent.click(view.getByRole("button", { name: "Project options for demo-project" })); - fireEvent.click(view.getByRole("button", { name: "Add sub-folder" })); - - await waitFor(() => { - expect(createSection).toHaveBeenCalledWith(demoProjectPath, "New sub-folder"); - }); - - let autoEditProps: - | (Parameters[0] & { - onAutoCreateAbandon?: () => void; - autoStartEditing?: boolean; - }) - | null = null; - const sectionHeaderCalls = ( - SectionHeaderModule.SectionHeader as unknown as { - mock: { - calls: Array<[Parameters[0]]>; - }; - } - ).mock.calls; - for (const [props] of sectionHeaderCalls) { - if (props.autoStartEditing) { - autoEditProps = props; - } - } - - expect(autoEditProps?.autoStartEditing).toBe(true); - expect(typeof autoEditProps?.onAutoCreateAbandon).toBe("function"); - - autoEditProps?.onAutoCreateAbandon?.(); - - await waitFor(() => { - expect(confirmDialogMock).toHaveBeenCalledWith({ - title: "Delete section?", - description: "1 workspace(s) in this section will be moved to unsectioned.", - confirmLabel: "Delete", - confirmVariant: "destructive", - }); - expect(removeSection).toHaveBeenCalledWith(demoProjectPath, "new-section"); - }); - }); - - test("marks section attention when a promoted draft workspace needs attention", () => { - const promotedWorkspace = { - ...createWorkspace("promoted-workspace", { title: "Promoted workspace" }), - sectionId: "section-1", - isInitializing: true, - }; - - projectContextValue = createProjectContextValue({ - userProjects: new Map([ - [ - demoProjectPath, - { - workspaces: [ - { - path: `${demoProjectPath}/promoted-workspace`, - sectionId: "section-1", - }, - ], - sections: [{ id: "section-1", name: "Section 1", color: "#6B7280", nextId: null }], - }, - ], - ]), - }); - - spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( - () => - ({ - selectedWorkspace: null, - setSelectedWorkspace: () => undefined, - preflightArchiveWorkspace: preflightArchiveWorkspaceMock, - archiveWorkspace: archiveWorkspaceActionMock, - removeWorkspace: () => Promise.resolve({ success: true }), - updateWorkspaceTitle: () => Promise.resolve({ success: true }), - refreshWorkspaceMetadata: () => Promise.resolve(), - pendingNewWorkspaceProject: null, - pendingNewWorkspaceDraftId: null, - workspaceDraftsByProject: { - [demoProjectPath]: [ - { - draftId: "draft-promoted", - sectionId: "section-1", - createdAt: Date.now(), - }, - ], - }, - workspaceDraftPromotionsByProject: { - [demoProjectPath]: { - "draft-promoted": promotedWorkspace, - }, - }, - createWorkspaceDraft: () => undefined, - openWorkspaceDraft: () => undefined, - deleteWorkspaceDraft: () => undefined, - }) as unknown as ReturnType - ); - - render( - undefined} - sortedWorkspacesByProject={new Map([[demoProjectPath, [promotedWorkspace]]])} - workspaceRecency={{}} - /> - ); - - const sectionHeaderCalls = ( - SectionHeaderModule.SectionHeader as unknown as { - mock: { - calls: Array<[Parameters[0]]>; - }; - } - ).mock.calls; - const sectionProps = sectionHeaderCalls - .map(([props]) => props) - .find((props) => props.section.id === "section-1"); - - expect(sectionProps?.hasAttention).toBe(true); - }); - test("supports inline project name editing with Enter, Escape, and empty-to-null commit", async () => { const updateDisplayName = mock(() => resolveVoidResult()); projectContextValue = createProjectContextValue({ @@ -1860,7 +1670,6 @@ describe("ProjectSidebar project actions menu", () => { [demoProjectPath]: [ { draftId: "draft-hidden-empty", - sectionId: null, createdAt: Date.now(), }, ], @@ -1896,7 +1705,6 @@ describe("ProjectSidebar project actions menu", () => { [demoProjectPath]: [ { draftId, - sectionId: null, createdAt: Date.now(), }, ], diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 862b9c79fc..faa46424c0 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -46,7 +46,6 @@ import { import { PlatformPaths } from "@/common/utils/paths"; import { partitionWorkspacesByAge, - partitionWorkspacesBySection, formatDaysThreshold, AGE_THRESHOLDS_DAYS, computeWorkspaceDepthMap, @@ -54,9 +53,6 @@ import { computeAgentRowRenderMeta, findNextNonEmptyTier, getTierKey, - getSectionExpandedKey, - getSectionTierKey, - sortSectionsByLinkedList, type AgentRowRenderMeta, } from "@/browser/utils/ui/workspaceFiltering"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; @@ -72,7 +68,6 @@ import { useSettings } from "@/browser/contexts/SettingsContext"; import { AgentListItem, type WorkspaceSelection } from "../AgentListItem/AgentListItem"; import { TaskGroupListItem } from "./TaskGroupListItem"; import { TitleEditProvider, useTitleEdit } from "@/browser/contexts/WorkspaceTitleEditContext"; -import { useConfirmDialog } from "@/browser/contexts/ConfirmDialogContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import { useContextMenuPosition } from "@/browser/hooks/useContextMenuPosition"; @@ -96,14 +91,9 @@ import { useRouter } from "@/browser/contexts/RouterContext"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { forkWorkspace } from "@/browser/utils/chatCommands"; 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 type { SectionConfig } from "@/common/types/project"; import { getErrorMessage } from "@/common/utils/errors"; import { isMultiProject } from "@/common/utils/multiProject"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; @@ -456,6 +446,90 @@ function DraftAgentListItemWrapper(props: DraftAgentListItemWrapperProps) { ); } +function normalizeProjectPathForTree(projectPath: string): string { + return projectPath.replace(/[\\/]+/g, "/").replace(/\/+$/, ""); +} + +function buildProjectTree(projectPaths: string[]): { + rootProjectPaths: string[]; + childProjectPathsByParent: Map; + parentProjectPathByPath: Map; + projectDepthByPath: Map; +} { + const normalizedProjectPathByPath = new Map( + projectPaths.map((projectPath) => [projectPath, normalizeProjectPathForTree(projectPath)]) + ); + const parentProjectPathByPath = new Map(); + + for (const projectPath of projectPaths) { + const normalizedProjectPath = normalizedProjectPathByPath.get(projectPath) ?? projectPath; + let parentProjectPath: string | null = null; + let parentPathLength = -1; + + for (const candidateProjectPath of projectPaths) { + if (candidateProjectPath === projectPath) { + continue; + } + + const normalizedCandidatePath = + normalizedProjectPathByPath.get(candidateProjectPath) ?? candidateProjectPath; + if (!normalizedProjectPath.startsWith(`${normalizedCandidatePath}/`)) { + continue; + } + if (normalizedCandidatePath.length <= parentPathLength) { + continue; + } + + parentProjectPath = candidateProjectPath; + parentPathLength = normalizedCandidatePath.length; + } + + parentProjectPathByPath.set(projectPath, parentProjectPath); + } + + const rootProjectPaths: string[] = []; + const childProjectPathsByParent = new Map(); + + for (const projectPath of projectPaths) { + const parentProjectPath = parentProjectPathByPath.get(projectPath) ?? null; + if (!parentProjectPath) { + rootProjectPaths.push(projectPath); + continue; + } + + const existingChildren = childProjectPathsByParent.get(parentProjectPath); + if (existingChildren) { + existingChildren.push(projectPath); + } else { + childProjectPathsByParent.set(parentProjectPath, [projectPath]); + } + } + + const projectDepthByPath = new Map(); + const resolveProjectDepth = (projectPath: string): number => { + const cached = projectDepthByPath.get(projectPath); + if (cached !== undefined) { + return cached; + } + + const parentProjectPath = parentProjectPathByPath.get(projectPath) ?? null; + const depth = parentProjectPath ? resolveProjectDepth(parentProjectPath) + 1 : 0; + projectDepthByPath.set(projectPath, depth); + return depth; + }; + + for (const projectPath of projectPaths) { + resolveProjectDepth(projectPath); + } + + return { + rootProjectPaths, + childProjectPathsByParent, + parentProjectPathByPath, + projectDepthByPath, + }; +} + // Custom drag layer to show a semi-transparent preview and enforce grabbing cursor interface ProjectDragItem { type: "PROJECT"; @@ -627,7 +701,6 @@ const ProjectSidebarInner: React.FC = ({ archiveWorkspace: onArchiveWorkspace, removeWorkspace, updateWorkspaceTitle: onUpdateTitle, - refreshWorkspaceMetadata, pendingNewWorkspaceProject, pendingNewWorkspaceDraftId, workspaceDraftsByProject, @@ -641,7 +714,6 @@ const ProjectSidebarInner: React.FC = ({ const runtimeStatusStore = useRuntimeStatusStoreRaw(); const { navigateToProject } = useRouter(); const { api } = useAPI(); - const { confirm: confirmDialog } = useConfirmDialog(); const settings = useSettings(); // Get project state and operations from context @@ -651,11 +723,6 @@ const ProjectSidebarInner: React.FC = ({ removeProject: onRemoveProject, updateDisplayName, updateColor: updateProjectColor, - createSection, - updateSection, - removeSection, - reorderSections, - assignWorkspaceToSection, } = useProjectContext(); // Theme for logo variant @@ -665,7 +732,6 @@ const ProjectSidebarInner: React.FC = ({ // Mobile breakpoint for auto-closing sidebar const MOBILE_BREAKPOINT = 768; - const NEW_SUB_FOLDER_PLACEHOLDER_NAME = "New sub-folder"; const projectListScrollRef = useRef(null); const mobileScrollTopRef = useRef(0); const wasCollapsedRef = useRef(collapsed); @@ -734,8 +800,8 @@ const ProjectSidebarInner: React.FC = ({ // Wrapper to close sidebar on mobile after adding workspace const handleAddWorkspace = useCallback( - (projectPath: string, sectionId?: string) => { - createWorkspaceDraft(projectPath, sectionId); + (projectPath: string) => { + createWorkspaceDraft(projectPath); if (window.innerWidth <= MOBILE_BREAKPOINT && !collapsed) { persistMobileSidebarScrollTop(mobileScrollTopRef.current); onToggleCollapsed(); @@ -746,8 +812,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(); @@ -776,7 +842,10 @@ const ProjectSidebarInner: React.FC = ({ // Use a plain array with .includes() instead of new Set() on every render — // the React Compiler cannot stabilize Set allocations (see AGENTS.md). // For typical sidebar sizes (< 20 projects) .includes() is equivalent perf. - const expandedProjectsList = Array.isArray(expandedProjectsArray) ? expandedProjectsArray : []; + const expandedProjectsList = React.useMemo( + () => (Array.isArray(expandedProjectsArray) ? expandedProjectsArray : []), + [expandedProjectsArray] + ); // Track which projects have old workspaces expanded (per-project, per-tier) // Key format: getTierKey(projectPath, tierIndex) where tierIndex is 0, 1, 2 for 1/7/30 days @@ -784,12 +853,6 @@ const ProjectSidebarInner: React.FC = ({ Record >("expandedOldWorkspaces", {}); - // Track which sections are expanded - const [expandedSections, setExpandedSections] = usePersistedState>( - "expandedSections", - {} - ); - // Track parent workspaces whose reported child tasks are expanded. const [expandedCompletedSubAgents, setExpandedCompletedSubAgents] = usePersistedState< Record @@ -842,7 +905,6 @@ const ProjectSidebarInner: React.FC = ({ archivedCount: number; } | null>(null); const projectRemoveError = usePopoverError(); - const sectionRemoveError = usePopoverError(); const handleDraftVisibilityChange = useCallback( (projectPath: string, draftId: string, isVisible: boolean) => { @@ -868,10 +930,6 @@ const ProjectSidebarInner: React.FC = ({ const [projectMenuTargetPath, setProjectMenuTargetPath] = useState(null); const [editingProjectPath, setEditingProjectPath] = useState(null); const [editingProjectDisplayName, setEditingProjectDisplayName] = useState(""); - const [autoEditingSection, setAutoEditingSection] = useState<{ - projectPath: string; - sectionId: string; - } | null>(null); const [showProjectColorPicker, setShowProjectColorPicker] = useState(false); const [projectColorHexInput, setProjectColorHexInput] = useState(""); const [projectColorPickerValue, setProjectColorPickerValue] = useState("#000000"); @@ -894,14 +952,6 @@ const ProjectSidebarInner: React.FC = ({ [setExpandedProjectsArray] ); - const toggleSection = (projectPath: string, sectionId: string) => { - const key = getSectionExpandedKey(projectPath, sectionId); - setExpandedSections((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }; - const handleForkWorkspace = useCallback( async (workspaceId: string, buttonElement?: HTMLElement) => { if (!api) { @@ -1263,49 +1313,6 @@ const ProjectSidebarInner: React.FC = ({ [removeWorkspace, workspaceRemoveError] ); - const handleRemoveSection = async ( - projectPath: string, - sectionId: string, - buttonElement?: HTMLElement - ) => { - // Capture the anchor location up front because the section action menu unmounts its - // button immediately after click; failures still need stable error placement. - const anchor = - buttonElement != null - ? (() => { - const buttonRect = buttonElement.getBoundingClientRect(); - return { - top: buttonRect.top + window.scrollY, - left: buttonRect.right + 10, - }; - })() - : undefined; - - // removeSection unsections every workspace in the project (including archived), - // so confirmation needs to count from the full project config. - const workspacesInSection = (userProjects.get(projectPath)?.workspaces ?? []).filter( - (workspace) => workspace.sectionId === sectionId - ); - - if (workspacesInSection.length > 0) { - const ok = await confirmDialog({ - title: "Delete section?", - description: `${workspacesInSection.length} workspace(s) in this section will be moved to unsectioned.`, - confirmLabel: "Delete", - confirmVariant: "destructive", - }); - if (!ok) { - return; - } - } - - const result = await removeSection(projectPath, sectionId); - if (!result.success) { - const error = result.error ?? "Failed to remove section"; - sectionRemoveError.showError(sectionId, error, anchor); - } - }; - const handleOpenSecrets = useCallback( (projectPath: string) => { // Collapse the off-canvas sidebar on mobile before navigating so the @@ -1450,38 +1457,6 @@ const ProjectSidebarInner: React.FC = ({ [closeProjectContextMenu, handleRequestProjectRemoval, projectMenuTargetPath] ); - const handleProjectMenuAddSubFolder = useCallback(() => { - if (!projectMenuTargetPath) { - return; - } - - const targetProjectPath = projectMenuTargetPath; - closeProjectContextMenu(); - void (async () => { - const result = await createSection(targetProjectPath, NEW_SUB_FOLDER_PLACEHOLDER_NAME); - if (!result.success) { - return; - } - setExpandedProjectsArray((prev) => { - const expanded = Array.isArray(prev) ? prev : []; - if (expanded.includes(targetProjectPath)) { - return expanded; - } - return [...expanded, targetProjectPath]; - }); - // New sub-folders should immediately open inline rename and stay visible. - const key = getSectionExpandedKey(targetProjectPath, result.data.id); - setExpandedSections((prev) => ({ ...prev, [key]: true })); - setAutoEditingSection({ projectPath: targetProjectPath, sectionId: result.data.id }); - })(); - }, [ - closeProjectContextMenu, - createSection, - projectMenuTargetPath, - setExpandedProjectsArray, - setExpandedSections, - ]); - const projectMenuTargetConfig = projectMenuTargetPath ? (userProjects.get(projectMenuTargetPath) ?? null) : null; @@ -1576,28 +1551,129 @@ const ProjectSidebarInner: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [projectPathsSignature, projectOrder] ); + const projectTree = React.useMemo( + () => buildProjectTree(sortedProjectPaths), + [sortedProjectPaths] + ); + + const autoExpandedProjectPaths = React.useMemo(() => { + const expandedProjectPaths = new Set(expandedProjectsList); + const activeProjectPaths = [pendingNewWorkspaceProject, selectedWorkspace?.projectPath].filter( + (projectPath): projectPath is string => + typeof projectPath === "string" && projectPath.length > 0 + ); - const singleProjectWorkspacesByProject = new Map(); - const multiProjectWorkspacesById = new Map(); - const workspaceAttentionById = new Map(); - - for (const [projectPath, workspaces] of sortedWorkspacesByProject) { - const singleProjectWorkspaces: FrontendWorkspaceMetadata[] = []; - for (const workspace of workspaces) { - workspaceAttentionById.set(workspace.id, workspaceHasAttention(workspace)); - if (isMultiProject(workspace)) { - if (multiProjectWorkspacesEnabled) { - multiProjectWorkspacesById.set(workspace.id, workspace); + for (const projectPath of activeProjectPaths) { + let currentProjectPath = projectPath; + while (currentProjectPath) { + expandedProjectPaths.add(currentProjectPath); + currentProjectPath = projectTree.parentProjectPathByPath.get(currentProjectPath) ?? ""; + } + } + + return expandedProjectPaths; + }, [ + expandedProjectsList, + pendingNewWorkspaceProject, + projectTree, + selectedWorkspace?.projectPath, + ]); + + const { multiProjectWorkspaces, singleProjectWorkspacesByProject, workspaceAttentionById } = + React.useMemo(() => { + const nextSingleProjectWorkspacesByProject = new Map(); + const nextMultiProjectWorkspacesById = new Map(); + const nextWorkspaceAttentionById = new Map(); + + for (const [projectPath, workspaces] of sortedWorkspacesByProject) { + const singleProjectWorkspaces: FrontendWorkspaceMetadata[] = []; + for (const workspace of workspaces) { + nextWorkspaceAttentionById.set(workspace.id, workspaceHasAttention(workspace)); + if (isMultiProject(workspace)) { + if (multiProjectWorkspacesEnabled) { + nextMultiProjectWorkspacesById.set(workspace.id, workspace); + } + continue; + } + + singleProjectWorkspaces.push(workspace); } - continue; + nextSingleProjectWorkspacesByProject.set(projectPath, singleProjectWorkspaces); + } + + return { + multiProjectWorkspaces: Array.from(nextMultiProjectWorkspacesById.values()), + singleProjectWorkspacesByProject: nextSingleProjectWorkspacesByProject, + workspaceAttentionById: nextWorkspaceAttentionById, + }; + }, [multiProjectWorkspacesEnabled, sortedWorkspacesByProject, workspaceHasAttention]); + const projectSummaryByPath = React.useMemo(() => { + const summaryByPath = new Map(); + + const resolveProjectSummary = ( + projectPath: string + ): { agentCount: number; hasAttention: boolean } => { + const cached = summaryByPath.get(projectPath); + if (cached) { + return cached; + } + + let agentCount = singleProjectWorkspacesByProject.get(projectPath)?.length ?? 0; + let hasAttention = (singleProjectWorkspacesByProject.get(projectPath) ?? []).some( + (workspace) => workspaceAttentionById.get(workspace.id) === true + ); + + for (const childProjectPath of projectTree.childProjectPathsByParent.get(projectPath) ?? []) { + const childSummary = resolveProjectSummary(childProjectPath); + agentCount += childSummary.agentCount; + hasAttention ||= childSummary.hasAttention; + } + + const summary = { agentCount, hasAttention }; + summaryByPath.set(projectPath, summary); + return summary; + }; + + for (const projectPath of sortedProjectPaths) { + resolveProjectSummary(projectPath); + } + + return summaryByPath; + }, [ + projectTree.childProjectPathsByParent, + singleProjectWorkspacesByProject, + sortedProjectPaths, + workspaceAttentionById, + ]); + const orderedProjectPaths = React.useMemo(() => { + const orderedPaths: string[] = []; + const visitProject = (projectPath: string): void => { + orderedPaths.push(projectPath); + for (const childProjectPath of projectTree.childProjectPathsByParent.get(projectPath) ?? []) { + visitProject(childProjectPath); } + }; - singleProjectWorkspaces.push(workspace); + for (const rootProjectPath of projectTree.rootProjectPaths) { + visitProject(rootProjectPath); } - singleProjectWorkspacesByProject.set(projectPath, singleProjectWorkspaces); - } - const multiProjectWorkspaces = Array.from(multiProjectWorkspacesById.values()); + return orderedPaths; + }, [projectTree]); + + const visibleProjectPaths = React.useMemo(() => { + return orderedProjectPaths.filter((projectPath) => { + let parentProjectPath = projectTree.parentProjectPathByPath.get(projectPath) ?? null; + while (parentProjectPath) { + if (!autoExpandedProjectPaths.has(parentProjectPath)) { + return false; + } + parentProjectPath = projectTree.parentProjectPathByPath.get(parentProjectPath) ?? null; + } + return true; + }); + }, [autoExpandedProjectPaths, orderedProjectPaths, projectTree.parentProjectPathByPath]); + // Multi-project rows should share the same completed-subagent chevron behavior as // regular workspace rows, so reuse the same visibility + metadata calculations. const multiProjectDepthByWorkspaceId = computeWorkspaceDepthMap(multiProjectWorkspaces); @@ -1651,7 +1727,6 @@ const ProjectSidebarInner: React.FC = ({ -
= ({
)} - {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? ( + {orderedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? (

No projects

) : ( - sortedProjectPaths.map((projectPath) => { + visibleProjectPaths.map((projectPath) => { const config = userProjects.get(projectPath); if (!config) return null; const projectFolderColor = config.color @@ -1783,19 +1858,29 @@ const ProjectSidebarInner: React.FC = ({ const sanitizedProjectId = projectPath.replace(/[^a-zA-Z0-9_-]/g, "-") || "root"; const workspaceListId = `workspace-list-${sanitizedProjectId}`; - const isExpanded = expandedProjectsList.includes(projectPath); + const isExpanded = autoExpandedProjectPaths.has(projectPath); + const projectDepth = projectTree.projectDepthByPath.get(projectPath) ?? 0; const displayProjectName = config.displayName ?? getProjectFallbackLabel(projectPath); const isEditingProjectDisplayName = editingProjectPath === projectPath; const projectWorkspaces = singleProjectWorkspacesByProject.get(projectPath) ?? []; - const projectAgentCount = projectWorkspaces.length; - const projectHasAttention = projectWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ); + const projectSummary = projectSummaryByPath.get(projectPath) ?? { + agentCount: projectWorkspaces.length, + hasAttention: projectWorkspaces.some( + (workspace) => workspaceAttentionById.get(workspace.id) === true + ), + }; + const projectAgentCount = projectSummary.agentCount; + const projectHasAttention = projectSummary.hasAttention; return ( -
+
0 ? { marginLeft: `${projectDepth * 12}px` } : undefined + } + > = ({ const workspacesForNormalRendering = allWorkspaces.filter( (workspace) => !promotedWorkspaceIds.has(workspace.id) ); - const sections = sortSectionsByLinkedList(config.sections ?? []); const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); const visibleWorkspacesForNormalRendering = filterVisibleAgentRows( workspacesForNormalRendering, @@ -2039,34 +2123,7 @@ const ProjectSidebarInner: React.FC = ({ (draft, index) => [draft.draftId, index + 1] as const ) ); - const sectionIds = new Set(sections.map((section) => section.id)); - const normalizeDraftSectionId = ( - draft: (typeof sortedDrafts)[number] - ): string | null => { - return typeof draft.sectionId === "string" && - sectionIds.has(draft.sectionId) - ? draft.sectionId - : 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); - if (sectionId === null) { - unsectionedDrafts.push(draft); - continue; - } - - const existing = draftsBySectionId.get(sectionId); - if (existing) { - existing.push(draft); - } else { - draftsBySectionId.set(sectionId, [draft]); - } - } + const unsectionedDrafts = sortedDrafts; const renderWorkspace = ( metadata: FrontendWorkspaceMetadata, @@ -2339,7 +2396,6 @@ const ProjectSidebarInner: React.FC = ({ const renderDraft = ( draft: (typeof sortedDrafts)[number] ): React.ReactNode => { - const sectionId = normalizeDraftSectionId(draft); const promotedMetadata = activeDraftPromotions[draft.draftId]; if (promotedMetadata) { @@ -2347,7 +2403,7 @@ const ProjectSidebarInner: React.FC = ({ allWorkspaces.find( (workspace) => workspace.id === promotedMetadata.id ) ?? promotedMetadata; - return renderWorkspace(liveMetadata, sectionId ?? undefined); + return renderWorkspace(liveMetadata); } const draftNumber = draftNumberById.get(draft.draftId) ?? 0; @@ -2362,7 +2418,6 @@ const ProjectSidebarInner: React.FC = ({ draftId={draft.draftId} draftNumber={draftNumber} isSelected={isSelected} - sectionId={sectionId ?? undefined} onVisibilityChange={(isVisible) => { handleDraftVisibilityChange( projectPath, @@ -2371,11 +2426,7 @@ const ProjectSidebarInner: React.FC = ({ ); }} onOpen={() => - handleOpenWorkspaceDraft( - projectPath, - draft.draftId, - sectionId - ) + handleOpenWorkspaceDraft(projectPath, draft.draftId) } onDelete={() => { if (isSelected) { @@ -2389,13 +2440,9 @@ const ProjectSidebarInner: React.FC = ({ : undefined; if (fallback) { - openWorkspaceDraft( - projectPath, - fallback.draftId, - normalizeDraftSectionId(fallback) - ); + openWorkspaceDraft(projectPath, fallback.draftId); } else { - navigateToProject(projectPath, sectionId ?? undefined); + navigateToProject(projectPath); } } @@ -2649,179 +2696,6 @@ const ProjectSidebarInner: React.FC = ({ ); }; - // Partition both the full section membership and the filtered visible rows. - // Best-of grouping stays leaf-only by consulting the unfiltered section data, - // while actual rendering still follows the visible hierarchy. - const { - unsectioned: allUnsectionedForNormalRendering, - bySectionId: allBySectionIdForNormalRendering, - } = partitionWorkspacesBySection( - workspacesForNormalRendering, - sections - ); - const { unsectioned, bySectionId } = partitionWorkspacesBySection( - visibleWorkspacesForNormalRendering, - sections - ); - - // Handle workspace drop into section - const handleWorkspaceSectionDrop = ( - workspaceId: string, - targetSectionId: string | null - ) => { - void (async () => { - const result = await assignWorkspaceToSection( - projectPath, - workspaceId, - targetSectionId - ); - if (result.success) { - // Refresh workspace metadata so UI shows updated sectionId - await refreshWorkspaceMetadata(); - } - })(); - }; - - // Handle section reorder (drag section onto another section) - const handleSectionReorder = ( - draggedSectionId: string, - targetSectionId: string - ) => { - void (async () => { - // Compute new order: move dragged section to position of target - const currentOrder = sections.map((s) => s.id); - const draggedIndex = currentOrder.indexOf(draggedSectionId); - const targetIndex = currentOrder.indexOf(targetSectionId); - - if (draggedIndex === -1 || targetIndex === -1) return; - - // Remove dragged from current position - const newOrder = [...currentOrder]; - newOrder.splice(draggedIndex, 1); - // Insert at target position - newOrder.splice(targetIndex, 0, draggedSectionId); - - await reorderSections(projectPath, newOrder); - })(); - }; - - // Render section with its workspaces - const renderSection = (section: SectionConfig) => { - const sectionWorkspaces = bySectionId.get(section.id) ?? []; - const sectionAllWorkspaces = - allBySectionIdForNormalRendering.get(section.id) ?? []; - const sectionDrafts = draftsBySectionId.get(section.id) ?? []; - const sectionHasPromotedAttention = sectionDrafts.some((draft) => { - const promotedMetadata = activeDraftPromotions[draft.draftId]; - return promotedMetadata - ? workspaceAttentionById.get(promotedMetadata.id) === true - : false; - }); - const sectionHasAttention = - sectionAllWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ) || sectionHasPromotedAttention; - - const sectionExpandedKey = getSectionExpandedKey( - projectPath, - section.id - ); - const isSectionExpanded = - expandedSections[sectionExpandedKey] ?? true; - const shouldAutoEditSection = - autoEditingSection?.projectPath === projectPath && - 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 updateSection(projectPath, section.id, { name }); - }} - onChangeColor={(color) => { - void updateSection(projectPath, section.id, { color }); - }} - autoStartEditing={shouldAutoEditSection} - onAutoCreateAbandon={ - shouldAutoEditSection - ? () => { - void (async () => { - setAutoEditingSection(null); - 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, - sectionAllWorkspaces - ) - ) : sectionDrafts.length === 0 ? ( -
- No chats in this sub-folder -
- ) : null} -
- )} -
-
- ); - }; - return ( <> {projectHasNoAgentsOrDrafts && ( @@ -2829,43 +2703,14 @@ const ProjectSidebarInner: React.FC = ({ Empty
)} - {/* Unsectioned workspaces first - always show drop zone when sections exist */} - {sections.length > 0 ? ( - - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 ? ( - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering - ) - ) : unsectionedDrafts.length === 0 ? ( -
- No unsectioned chats -
- ) : null} -
- ) : ( - <> - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 && - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering - )} - - )} - - {/* Sections */} - {sections.map(renderSection)} + {unsectionedDrafts.map((draft) => renderDraft(draft))} + {visibleWorkspacesForNormalRendering.length > 0 && + renderAgeTiers( + visibleWorkspacesForNormalRendering, + getTierKey(projectPath, 0).replace(":0", ""), + undefined, + workspacesForNormalRendering + )} ); })()} @@ -2897,14 +2742,6 @@ const ProjectSidebarInner: React.FC = ({ handleProjectMenuEditName(); }} /> - } - label="Add sub-folder" - disabled={!hasProjectMenuTarget} - onClick={() => { - handleProjectMenuAddSubFolder(); - }} - /> } label="Manage secrets" @@ -3040,11 +2877,6 @@ const ProjectSidebarInner: React.FC = ({ prefix="Failed to remove project" onDismiss={projectRemoveError.clearError} /> -
diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index b90e5e80ac..d4e6b1b156 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { useAPI } from "@/browser/contexts/API"; -import type { ProjectConfig, SectionConfig } from "@/common/types/project"; +import type { ProjectConfig } from "@/common/types/project"; import type { BranchListResult } from "@/common/orpc/types"; import type { z } from "zod"; import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors"; @@ -90,25 +90,6 @@ export interface ProjectContext { updateSecrets: (projectPath: string, secrets: Secret[]) => Promise; updateDisplayName: (projectPath: string, displayName: string | null) => Promise>; updateColor: (projectPath: string, color: string | null) => Promise>; - - // Section operations - createSection: ( - projectPath: string, - name: string, - color?: string - ) => Promise>; - updateSection: ( - projectPath: string, - sectionId: string, - updates: { name?: string; color?: string } - ) => Promise>; - removeSection: (projectPath: string, sectionId: string) => Promise>; - reorderSections: (projectPath: string, sectionIds: string[]) => Promise>; - assignWorkspaceToSection: ( - projectPath: string, - workspaceId: string, - sectionId: string | null - ) => Promise>; /** Whether any project (user or system) is loaded. */ hasAnyProject: boolean; /** Resolve the target project for a new-chat deep link. Tries explicit selectors, then falls back to default. */ @@ -486,79 +467,6 @@ export function ProjectProvider(props: { children: ReactNode }) { [api, refreshProjects] ); - // Section operations - const createSection = useCallback( - async (projectPath: string, name: string, color?: string): Promise> => { - if (!api) return { success: false, error: "API not connected" }; - const result = await api.projects.sections.create({ projectPath, name, color }); - if (result.success) { - await refreshProjects(); - } - return result; - }, - [api, refreshProjects] - ); - - const updateSection = useCallback( - async ( - projectPath: string, - sectionId: string, - updates: { name?: string; color?: string } - ): Promise> => { - if (!api) return { success: false, error: "API not connected" }; - const result = await api.projects.sections.update({ projectPath, sectionId, ...updates }); - if (result.success) { - await refreshProjects(); - } - return result; - }, - [api, refreshProjects] - ); - - const removeSection = useCallback( - async (projectPath: string, sectionId: string): Promise> => { - if (!api) return { success: false, error: "API not connected" }; - const result = await api.projects.sections.remove({ projectPath, sectionId }); - if (result.success) { - await refreshProjects(); - } - return result; - }, - [api, refreshProjects] - ); - - const reorderSections = useCallback( - async (projectPath: string, sectionIds: string[]): Promise> => { - if (!api) return { success: false, error: "API not connected" }; - const result = await api.projects.sections.reorder({ projectPath, sectionIds }); - if (result.success) { - await refreshProjects(); - } - return result; - }, - [api, refreshProjects] - ); - - const assignWorkspaceToSection = useCallback( - async ( - projectPath: string, - workspaceId: string, - sectionId: string | null - ): Promise> => { - if (!api) return { success: false, error: "API not connected" }; - const result = await api.projects.sections.assignWorkspace({ - projectPath, - workspaceId, - sectionId, - }); - if (result.success) { - await refreshProjects(); - } - return result; - }, - [api, refreshProjects] - ); - const value = useMemo( () => ({ userProjects, @@ -582,11 +490,6 @@ export function ProjectProvider(props: { children: ReactNode }) { updateSecrets, updateDisplayName, updateColor, - createSection, - updateSection, - removeSection, - reorderSections, - assignWorkspaceToSection, }), [ userProjects, @@ -608,11 +511,6 @@ export function ProjectProvider(props: { children: ReactNode }) { updateSecrets, updateDisplayName, updateColor, - createSection, - updateSection, - removeSection, - reorderSections, - assignWorkspaceToSection, ] ); diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx index 441f97b7d9..c180f5fdcd 100644 --- a/src/browser/contexts/RouterContext.tsx +++ b/src/browser/contexts/RouterContext.tsx @@ -20,7 +20,7 @@ import { getProjectRouteId } from "@/common/utils/projectRouteId"; export interface RouterContext { navigateToWorkspace: (workspaceId: string) => void; - navigateToProject: (projectPath: string, sectionId?: string, draftId?: string) => void; + navigateToProject: (projectPath: string, draftId?: string) => void; navigateToHome: () => void; navigateToSettings: (section?: string) => void; navigateFromSettings: () => void; @@ -37,9 +37,6 @@ export interface RouterContext { /** Optional project path carried via in-memory navigation state (not persisted on refresh). */ currentProjectPathFromState: string | null; - /** Section ID for pending workspace creation (from URL) */ - pendingSectionId: string | null; - /** Draft ID for UI-only workspace creation drafts (from URL) */ pendingDraftId: string | null; @@ -294,14 +291,10 @@ function RouterContextInner(props: { children: ReactNode }) { const legacyPath = params.get("path"); const projectParam = params.get("project"); if (!projectParam && legacyPath) { - const section = params.get("section"); const draft = params.get("draft"); const projectId = getProjectRouteId(legacyPath); const nextParams = new URLSearchParams(); nextParams.set("project", projectId); - if (section) { - nextParams.set("section", section); - } if (draft) { nextParams.set("draft", draft); } @@ -309,7 +302,6 @@ function RouterContextInner(props: { children: ReactNode }) { void navigateRef.current(url, { replace: true, state: { projectPath: legacyPath } }); } }, [location.pathname, location.search]); - const pendingSectionId = location.pathname === "/project" ? searchParams.get("section") : null; const pendingDraftId = location.pathname === "/project" ? searchParams.get("draft") : null; // Navigation functions use push (not replace) to build history for back/forward navigation. @@ -318,13 +310,10 @@ function RouterContextInner(props: { children: ReactNode }) { void navigateRef.current(`/workspace/${encodeURIComponent(id)}`); }, []); - const navigateToProject = useCallback((path: string, sectionId?: string, draftId?: string) => { + const navigateToProject = useCallback((path: string, draftId?: string) => { const projectId = getProjectRouteId(path); const params = new URLSearchParams(); params.set("project", projectId); - if (sectionId) { - params.set("section", sectionId); - } if (draftId) { params.set("draft", draftId); } @@ -380,7 +369,6 @@ function RouterContextInner(props: { children: ReactNode }) { currentSettingsSection, currentProjectId, currentProjectPathFromState, - pendingSectionId, pendingDraftId, isAnalyticsOpen, }), @@ -396,7 +384,6 @@ function RouterContextInner(props: { children: ReactNode }) { currentSettingsSection, currentProjectId, currentProjectPathFromState, - pendingSectionId, pendingDraftId, isAnalyticsOpen, ] diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 106e184596..c535898f1f 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -272,7 +272,6 @@ function ensureCreatedAt(metadata: FrontendWorkspaceMetadata): void { export interface WorkspaceDraft { draftId: string; - sectionId: string | null; createdAt: number; } @@ -283,15 +282,12 @@ type WorkspaceDraftPromotionsByProject = Record 0 && typeof record.createdAt === "number" && - Number.isFinite(record.createdAt) && - (record.sectionId === null || - record.sectionId === undefined || - typeof record.sectionId === "string") + Number.isFinite(record.createdAt) ); } @@ -309,14 +305,8 @@ function normalizeWorkspaceDraftsByProject(value: unknown): WorkspaceDraftsByPro for (const draft of drafts) { if (!isWorkspaceDraft(draft)) continue; - const normalizedSectionId = - typeof draft.sectionId === "string" && draft.sectionId.trim().length > 0 - ? draft.sectionId - : null; - nextDrafts.push({ draftId: draft.draftId, - sectionId: normalizedSectionId, createdAt: draft.createdAt, }); } @@ -370,23 +360,14 @@ function isDraftEmpty(projectPath: string, draftId: string): boolean { } /** - * Find an existing empty draft for a project (optionally within a specific section). + * Find an existing empty draft for a project. * Returns the draft ID if found, or null if no empty draft exists. */ function findExistingEmptyDraft( workspaceDrafts: WorkspaceDraft[], - projectPath: string, - sectionId?: string + projectPath: string ): string | null { - const normalizedSectionId = sectionId ?? null; - for (const draft of workspaceDrafts) { - // Keep draft reuse scoped to the current section. When sectionId is undefined - // (project-level "New Workspace"), only reuse drafts with a null section so - // we don't silently move section-specific drafts into the root flow. - if ((draft.sectionId ?? null) !== normalizedSectionId) { - continue; - } if (isDraftEmpty(projectPath, draft.draftId)) { return draft.draftId; } @@ -459,22 +440,15 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue { // Workspace creation flow pendingNewWorkspaceProject: string | null; - /** Section ID to pre-select when creating a new workspace (from URL) */ - pendingNewWorkspaceSectionId: string | null; /** 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, sectionId?: string) => void; + beginWorkspaceCreation: (projectPath: string) => void; // UI-only workspace creation drafts (placeholders) workspaceDraftsByProject: WorkspaceDraftsByProject; - createWorkspaceDraft: (projectPath: string, sectionId?: string) => void; - updateWorkspaceDraftSection: ( - projectPath: string, - draftId: string, - sectionId: string | null - ) => void; - openWorkspaceDraft: (projectPath: string, draftId: string, sectionId?: string | null) => void; + createWorkspaceDraft: (projectPath: string) => void; + openWorkspaceDraft: (projectPath: string, draftId: string) => void; deleteWorkspaceDraft: (projectPath: string, draftId: string) => void; // Helpers @@ -570,7 +544,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { currentProjectPathFromState, currentSettingsSection, isAnalyticsOpen, - pendingSectionId, pendingDraftId, } = useRouter(); const location = useLocation(); @@ -656,11 +629,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return; } - const normalizedSectionId = - typeof payload.sectionId === "string" && payload.sectionId.trim().length > 0 - ? payload.sectionId - : 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. const draftId = createWorkspaceDraftId(); @@ -676,7 +644,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ...existing, { draftId, - sectionId: normalizedSectionId, createdAt, }, ], @@ -692,7 +659,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { updatePersistedState(getInputKey(getDraftScopeId(resolvedProjectPath, draftId)), prompt); } - navigateToProject(resolvedProjectPath, normalizedSectionId ?? undefined, draftId); + navigateToProject(resolvedProjectPath, draftId); }, [ api, @@ -799,8 +766,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // pendingNewWorkspaceProject is derived from current project in URL/state const pendingNewWorkspaceProject = currentProjectPath; - // pendingNewWorkspaceSectionId is derived from section URL param - const pendingNewWorkspaceSectionId = pendingSectionId; const pendingNewWorkspaceDraftId = pendingNewWorkspaceProject ? pendingDraftId : null; // selectedWorkspace is derived from currentWorkspaceId in URL + workspaceMetadata @@ -1456,59 +1421,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [] ); const beginWorkspaceCreation = useCallback( - (projectPath: string, sectionId?: string) => { - navigateToProject(projectPath, sectionId); + (projectPath: string) => { + navigateToProject(projectPath); }, [navigateToProject] ); - // Persist section selection + URL updates so draft section switches stick across navigation. - const updateWorkspaceDraftSection = useCallback( - (projectPath: string, draftId: string, sectionId: string | null) => { - if (projectPath.trim().length === 0) return; - if (draftId.trim().length === 0) return; - - const normalizedSectionId = - typeof sectionId === "string" && sectionId.trim().length > 0 ? sectionId : null; - - setWorkspaceDraftsByProjectState((prev) => { - const current = normalizeWorkspaceDraftsByProject(prev); - const existing = current[projectPath] ?? []; - if (existing.length === 0) { - return prev; - } - - let didUpdate = false; - const nextDrafts = existing.map((draft) => { - if (draft.draftId !== draftId) { - return draft; - } - if (draft.sectionId === normalizedSectionId) { - return draft; - } - didUpdate = true; - return { - ...draft, - sectionId: normalizedSectionId, - }; - }); - - if (!didUpdate) { - return prev; - } - - return { - ...current, - [projectPath]: nextDrafts, - }; - }); - - navigateToProject(projectPath, normalizedSectionId ?? undefined, draftId); - }, - [navigateToProject, setWorkspaceDraftsByProjectState] - ); const createWorkspaceDraft = useCallback( - (projectPath: string, sectionId?: string) => { + (projectPath: string) => { // Read directly from localStorage to get the freshest value, avoiding stale closure issues. // The React state (workspaceDraftsByProject) may be out of date if this is called rapidly. const freshDrafts = normalizeWorkspaceDraftsByProject( @@ -1516,11 +1436,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ); const existingDrafts = freshDrafts[projectPath] ?? []; - // If there's an existing empty draft (optionally in the same section), reuse it - // instead of creating yet another empty draft. - const existingEmptyDraftId = findExistingEmptyDraft(existingDrafts, projectPath, sectionId); + // If there's an existing empty draft, reuse it instead of creating yet another empty draft. + const existingEmptyDraftId = findExistingEmptyDraft(existingDrafts, projectPath); if (existingEmptyDraftId) { - navigateToProject(projectPath, sectionId, existingEmptyDraftId); + navigateToProject(projectPath, existingEmptyDraftId); return; } @@ -1528,7 +1447,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const createdAt = Date.now(); const draft: WorkspaceDraft = { draftId, - sectionId: sectionId ?? null, createdAt, }; @@ -1558,7 +1476,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { }; }); - navigateToProject(projectPath, sectionId, draftId); + navigateToProject(projectPath, draftId); }, [navigateToProject, setWorkspaceDraftsByProjectState] ); @@ -1640,10 +1558,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ]); const openWorkspaceDraft = useCallback( - (projectPath: string, draftId: string, sectionId?: string | null) => { - const normalizedSectionId = - typeof sectionId === "string" && sectionId.trim().length > 0 ? sectionId : undefined; - navigateToProject(projectPath, normalizedSectionId, draftId); + (projectPath: string, draftId: string) => { + navigateToProject(projectPath, draftId); }, [navigateToProject] ); @@ -1707,14 +1623,12 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { selectedWorkspace, setSelectedWorkspace, pendingNewWorkspaceProject, - pendingNewWorkspaceSectionId, pendingNewWorkspaceDraftId, beginWorkspaceCreation, workspaceDraftsByProject, workspaceDraftPromotionsByProject, promoteWorkspaceDraft, createWorkspaceDraft, - updateWorkspaceDraftSection, openWorkspaceDraft, deleteWorkspaceDraft, getWorkspaceInfo, @@ -1731,14 +1645,12 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { selectedWorkspace, setSelectedWorkspace, pendingNewWorkspaceProject, - pendingNewWorkspaceSectionId, pendingNewWorkspaceDraftId, beginWorkspaceCreation, workspaceDraftsByProject, workspaceDraftPromotionsByProject, promoteWorkspaceDraft, createWorkspaceDraft, - updateWorkspaceDraftSection, openWorkspaceDraft, deleteWorkspaceDraft, getWorkspaceInfo, diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index db4751f3d8..83022d1797 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -20,7 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/SelectPrimitive/SelectPrimitive"; -import { Blocks, Cog, GitBranch, Loader2, Wand2, X } from "lucide-react"; +import { Blocks, Cog, GitBranch, Loader2, Wand2 } from "lucide-react"; import { PlatformPaths } from "@/common/utils/paths"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useSettings } from "@/browser/contexts/SettingsContext"; @@ -40,8 +40,6 @@ import { import type { WorkspaceNameState, WorkspaceNameUIError } from "@/browser/hooks/useWorkspaceName"; import type { CoderInfo } from "@/common/orpc/schemas/coder"; -import type { SectionConfig } from "@/common/types/project"; -import { resolveSectionColor } from "@/common/constants/ui"; import { CoderAvailabilityMessage, CoderWorkspaceForm, @@ -128,12 +126,6 @@ interface CreationControlsProps { runtimeAvailabilityState: RuntimeAvailabilityState; /** Runtime enablement toggles from Settings (hide disabled runtimes). */ runtimeEnablement?: RuntimeEnablement; - /** Available sections for this project */ - sections?: SectionConfig[]; - /** Currently selected section ID */ - selectedSectionId?: string | null; - /** Callback when section selection changes */ - onSectionChange?: (sectionId: string | null) => void; /** Which runtime field (if any) is in error state for visual feedback */ runtimeFieldError?: "docker" | "ssh" | null; @@ -321,113 +313,6 @@ const resolveRuntimeButtonState = ( }; }; -/** Aesthetic section picker with color accent */ -interface SectionPickerProps { - sections: SectionConfig[]; - selectedSectionId: string | null; - onSectionChange: (sectionId: string | null) => void; - disabled?: boolean; -} - -function SectionSelectItem(props: { section: SectionConfig }) { - const color = resolveSectionColor(props.section.color); - - return ( - - - {props.section.name} - - ); -} - -function SectionPicker(props: SectionPickerProps) { - const { sections, selectedSectionId, onSectionChange, disabled } = props; - - // Radix Select treats `""` as an "unselected" value; normalize any accidental - // empty-string IDs back to null so the UI stays consistent. - const normalizedSelectedSectionId = - selectedSectionId && selectedSectionId.trim().length > 0 ? selectedSectionId : null; - - const selectedSection = normalizedSelectedSectionId - ? sections.find((s) => s.id === normalizedSelectedSectionId) - : null; - const sectionColor = resolveSectionColor(selectedSection?.color); - - return ( -
- onSectionChange(value.trim() ? value : null)} - disabled={disabled} - > - {/* Trigger IS the full pill so Radix aligns the dropdown to it. */} - - {/* Color indicator dot */} -
- Section - - - - {sections.map((section) => ( - - ))} - - - {/* Clear button is a sibling (not nested in the trigger) to avoid - nesting interactive elements. Absolutely positioned over the - right padding reserved by the trigger's pr-8. */} - {normalizedSelectedSectionId && ( - - - - - Clear section - - )} -
- ); -} - export function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const state = props.runtimeAvailabilityState; const availabilityMap = state?.status === "loaded" ? state.data : null; @@ -939,35 +824,8 @@ export function CreationControls(props: CreationControlsProps) {
{nameState.error && } - - {/* Section selector - inline on desktop, hidden on mobile (shown separately below) */} - {props.sections && props.sections.length > 0 && props.onSectionChange && ( - <> -
-
- -
- - )}
- {/* Section selector - own row on mobile only, hidden on desktop */} - {props.sections && props.sections.length > 0 && props.onSectionChange && ( -
- -
- )} - {/* Runtime and source branch controls */}
diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 0143eea419..c8894acedc 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -183,7 +183,6 @@ const ChatInputInner: React.FC = (props) => { ); const { variant } = props; const creationProjectPath = variant === "creation" ? props.projectPath : ""; - const creationDraftId = variant === "creation" ? props.pendingDraftId : null; const [thinkingLevel] = useThinkingLevel(); const atMentionProjectPath = variant === "creation" ? props.projectPath : null; const workspaceId = variant === "workspace" ? props.workspaceId : null; @@ -507,8 +506,7 @@ const ChatInputInner: React.FC = (props) => { const preEditDraftRef = useRef({ text: "", attachments: [] }); const preEditReviewsRef = useRef(null); const { open } = useSettings(); - const { selectedWorkspace, beginWorkspaceCreation, updateWorkspaceDraftSection } = - useWorkspaceContext(); + const { selectedWorkspace } = useWorkspaceContext(); const { agentId, currentAgent } = useAgent(); // Use current agent's uiColor, or neutral border until agents load @@ -719,9 +717,7 @@ const ChatInputInner: React.FC = (props) => { const openModelSelector = useCallback(() => { modelSelectorRef.current?.open(); }, []); - // Section selection state for creation variant (must be before useCreationWorkspace) const { userProjects } = useProjectContext(); - const pendingSectionId = variant === "creation" ? (props.pendingSectionId ?? null) : null; const creationProject = variant === "creation" ? userProjects.get(props.projectPath) : undefined; const hasCreationRuntimeOverrides = creationProject?.runtimeOverridesEnabled === true || @@ -732,66 +728,9 @@ const ChatInputInner: React.FC = (props) => { variant === "creation" && hasCreationRuntimeOverrides ? normalizeRuntimeEnablement(creationProject?.runtimeEnablement) : runtimeEnablement; - const creationSections = creationProject?.sections ?? []; - const [selectedSectionId, setSelectedSectionId] = useState(() => pendingSectionId); const [hasAttemptedCreateSend, setHasAttemptedCreateSend] = useState(false); - // Keep local selection in sync with the URL-driven pending section (sidebar "+" button). - useEffect(() => { - if (variant !== "creation") { - return; - } - - setSelectedSectionId(pendingSectionId); - }, [pendingSectionId, variant]); - - // If the section disappears (e.g. deleted in another window), avoid creating a workspace - // with a dangling sectionId. - useEffect(() => { - if (variant !== "creation") { - return; - } - - if (!creationProject || !selectedSectionId) { - return; - } - - const stillExists = (creationProject.sections ?? []).some( - (section) => section.id === selectedSectionId - ); - if (!stillExists) { - setSelectedSectionId(null); - } - }, [creationProject, selectedSectionId, variant]); - - const handleCreationSectionChange = useCallback( - (sectionId: string | null) => { - setSelectedSectionId(sectionId); - - if (variant !== "creation") { - return; - } - - if (typeof creationDraftId === "string" && creationDraftId.trim().length > 0) { - updateWorkspaceDraftSection(creationProjectPath, creationDraftId, sectionId); - return; - } - - beginWorkspaceCreation( - creationProjectPath, - typeof sectionId === "string" && sectionId.trim().length > 0 ? sectionId : undefined - ); - }, - [ - beginWorkspaceCreation, - creationDraftId, - creationProjectPath, - updateWorkspaceDraftSection, - variant, - ] - ); - // Creation-specific state (hook always called, but only used when variant === "creation") // This avoids conditional hook calls which violate React rules const creationState = useCreationWorkspace( @@ -800,7 +739,6 @@ const ChatInputInner: React.FC = (props) => { projectPath: props.projectPath, onWorkspaceCreated: props.onWorkspaceCreated, message: input, - sectionId: selectedSectionId, draftId: props.pendingDraftId, userModel: preferredModel, } @@ -876,9 +814,6 @@ const ChatInputInner: React.FC = (props) => { nameState: creationState.nameState, runtimeAvailabilityState: creationState.runtimeAvailabilityState, runtimeEnablement: creationRuntimeEnablement, - sections: creationSections, - selectedSectionId, - onSectionChange: handleCreationSectionChange, allowedRuntimeModes: runtimePolicy.allowedModes, allowSshHost: runtimePolicy.allowSshHost, allowSshCoder: runtimePolicy.allowSshCoder, diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 5667bfb9b7..b25e4e7cf9 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -60,8 +60,6 @@ export interface ChatInputCreationVariant { variant: "creation"; projectPath: string; projectName: string; - /** Section ID to pre-select (from sidebar section "+" button) */ - pendingSectionId?: string | null; /** Draft ID for UI-only workspace creation drafts (from URL) */ pendingDraftId?: string | null; onWorkspaceCreated: ( diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index b559f9e4b9..07b57ca947 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -66,8 +66,6 @@ interface UseCreationWorkspaceOptions { ) => void; /** Current message input for name generation */ message: string; - /** Section ID to assign the new workspace to */ - sectionId?: string | null; /** Draft ID for UI-only workspace creation drafts (from URL) */ draftId?: string | null; /** User's currently selected model (for name generation fallback) */ @@ -206,7 +204,6 @@ export function useCreationWorkspace({ projectPath, onWorkspaceCreated, message, - sectionId, draftId, userModel, }: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { @@ -492,7 +489,6 @@ export function useCreationWorkspace({ trunkBranch: settings.trunkBranch, title: createTitle, runtimeConfig, - sectionId: sectionId ?? undefined, }); if (!createResult.success) { @@ -631,7 +627,6 @@ export function useCreationWorkspace({ waitForGeneration, workspaceNameState.autoGenerate, workspaceNameState.name, - sectionId, draftId, promoteWorkspaceDraft, deleteWorkspaceDraft, diff --git a/src/browser/stories/App.phoneViewports.stories.tsx b/src/browser/stories/App.phoneViewports.stories.tsx index ed01580ad0..0312a5fe72 100644 --- a/src/browser/stories/App.phoneViewports.stories.tsx +++ b/src/browser/stories/App.phoneViewports.stories.tsx @@ -239,56 +239,37 @@ export const IPhone17ProMaxTouchReviewImmersive: AppStory = { }; /** - * Mobile sidebar with a project containing a custom section. - * Verifies section header action buttons (+, color, rename, delete) are visible - * on touch devices where hover state doesn't exist. + * Mobile sidebar with a nested sub-project path. + * Verifies the child project renders under its parent on touch-sized layouts. */ -export const IPhone16eSidebarWithSections: AppStory = { +export const IPhone16eSidebarWithSubProjects: AppStory = { render: () => ( { const projectPath = "/home/user/projects/my-app"; - const sectionId = "sec00001"; + const childProjectPath = `${projectPath}/packages/payments`; const workspaces = [ createWorkspace({ - id: "ws-unsectioned", + id: "ws-parent", name: "main", projectName: "my-app", projectPath, }), - { - ...createWorkspace({ - id: "ws-in-section-1", - name: "feature/auth", - projectName: "my-app", - projectPath, - }), - sectionId, - }, - { - ...createWorkspace({ - id: "ws-in-section-2", - name: "feature/payments", - projectName: "my-app", - projectPath, - }), - sectionId, - }, + createWorkspace({ + id: "ws-child", + name: "feature/payments", + projectName: "payments", + projectPath: childProjectPath, + }), ]; - // Build project config with a custom section const projects = groupWorkspacesByProject(workspaces); - const projectConfig = projects.get(projectPath)!; - projects.set(projectPath, { - ...projectConfig, - sections: [{ id: sectionId, name: "Features", color: "#6366f1", nextId: null }], - }); - // Sidebar open with no workspace selected so the sidebar content is visible + // Sidebar open with no workspace selected so the sidebar content is visible. clearWorkspaceSelection(); collapseRightSidebar(); - expandProjects([projectPath]); + expandProjects([projectPath, childProjectPath]); window.localStorage.setItem(LEFT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(false)); return createMockORPCClient({ projects, workspaces }); @@ -308,17 +289,9 @@ export const IPhone16eSidebarWithSections: AppStory = { }, }, play: async ({ canvasElement }) => { - // No workspace is selected so there's no ChatInput to wait for; - // skip stabilizePhoneViewportStory and wait for the section directly. await waitFor( () => { - const sectionHeader = canvasElement.querySelector('[data-section-id="sec00001"]'); - if (!sectionHeader) throw new Error("Section header not found"); - // Verify the section header action buttons are in the DOM. - // The actual visibility assertion (opacity via CSS media query) is - // validated by the Chromatic snapshot in touch mode — the Storybook - // test runner doesn't emulate pointer:coarse media queries. - within(sectionHeader as HTMLElement).getByLabelText("New chat in section"); + within(canvasElement).getByText("payments"); }, { timeout: 10_000 } ); diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 89c58ecc83..d4f8bca934 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -1036,7 +1036,7 @@ describe("partitionWorkspacesBySection", () => { id: string, sectionId?: string, parentWorkspaceId?: string - ): FrontendWorkspaceMetadata => ({ + ): FrontendWorkspaceMetadata & { sectionId?: string } => ({ id, name: `workspace-${id}`, projectName: "test-project", diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 9818e6d11e..75c9f70f9d 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -540,13 +540,17 @@ export function partitionWorkspacesByAge( * - unsectioned: workspaces not assigned to any section * - bySectionId: map of section ID to workspaces in that section */ +type LegacySectionWorkspace = FrontendWorkspaceMetadata & { + sectionId?: string; +}; + interface SectionPartitionResult { - unsectioned: FrontendWorkspaceMetadata[]; - bySectionId: Map; + unsectioned: LegacySectionWorkspace[]; + bySectionId: Map; } /** - * Partition workspaces by their sectionId. + * Partition workspaces by their legacy sectionId. * Preserves input order within each partition. * * @param workspaces - All workspaces for the project (in display order) @@ -554,12 +558,12 @@ interface SectionPartitionResult { * @returns Partitioned workspaces */ export function partitionWorkspacesBySection( - workspaces: FrontendWorkspaceMetadata[], + workspaces: LegacySectionWorkspace[], sections: SectionConfig[] ): SectionPartitionResult { const sectionIds = new Set(sections.map((s) => s.id)); - const unsectioned: FrontendWorkspaceMetadata[] = []; - const bySectionId = new Map(); + const unsectioned: LegacySectionWorkspace[] = []; + const bySectionId = new Map(); // Initialize all sections with empty arrays to ensure consistent ordering for (const section of sections) { @@ -567,13 +571,13 @@ export function partitionWorkspacesBySection( } // Build workspace lookup for parent resolution - const byId = new Map(); + const byId = new Map(); for (const workspace of workspaces) { byId.set(workspace.id, workspace); } // Resolve effective section for a workspace (inherit from parent if unset) - const resolveSection = (workspace: FrontendWorkspaceMetadata): string | undefined => { + const resolveSection = (workspace: LegacySectionWorkspace): string | undefined => { if (workspace.sectionId && sectionIds.has(workspace.sectionId)) { return workspace.sectionId; } diff --git a/src/browser/utils/workspace.ts b/src/browser/utils/workspace.ts index f542230af8..1ca3e6986e 100644 --- a/src/browser/utils/workspace.ts +++ b/src/browser/utils/workspace.ts @@ -20,6 +20,5 @@ export function getWorkspaceSidebarKey(meta: FrontendWorkspaceMetadata): string meta.parentWorkspaceId ?? "", // Nested sidebar indentation/order meta.taskStatus ?? "", // Task lifecycle label/state for sub-agent rows meta.agentType ?? "", // Agent preset badge/label (future) - meta.sectionId ?? "", // Section grouping for sidebar organization ].join("|"); } diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index a6386d608e..705790b158 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -11,7 +11,7 @@ import { SendMessageErrorSchema, } from "./errors"; import { BranchListResultSchema, FilePartSchema, MuxMessageSchema } from "./message"; -import { ProjectConfigSchema, SectionConfigSchema } from "./project"; +import { ProjectConfigSchema } from "./project"; import { ResultSchema } from "./result"; import { SshPromptEventSchema, SshPromptResponseInputSchema } from "./ssh"; import { @@ -750,51 +750,6 @@ export const projects = { output: ResultSchema(z.void(), z.string()), }, }, - sections: { - list: { - input: z.object({ projectPath: z.string() }), - output: z.array(SectionConfigSchema), - }, - create: { - input: z.object({ - projectPath: z.string(), - name: z.string().min(1), - color: z.string().optional(), - }), - output: ResultSchema(SectionConfigSchema, z.string()), - }, - update: { - input: z.object({ - projectPath: z.string(), - sectionId: z.string(), - name: z.string().min(1).optional(), - color: z.string().optional(), - }), - output: ResultSchema(z.void(), z.string()), - }, - remove: { - input: z.object({ - projectPath: z.string(), - sectionId: z.string(), - }), - output: ResultSchema(z.void(), z.string()), - }, - reorder: { - input: z.object({ - projectPath: z.string(), - sectionIds: z.array(z.string()), - }), - output: ResultSchema(z.void(), z.string()), - }, - assignWorkspace: { - input: z.object({ - projectPath: z.string(), - workspaceId: z.string(), - sectionId: z.string().nullable(), - }), - output: ResultSchema(z.void(), z.string()), - }, - }, }; /** @@ -928,8 +883,6 @@ export const workspace = { /** Human-readable title (e.g., "Fix plan mode over SSH") - optional for backwards compat */ title: z.string().optional(), runtimeConfig: RuntimeConfigSchema.optional(), - /** Section ID to assign the new workspace to (optional) */ - sectionId: z.string().optional(), }), output: z.discriminatedUnion("success", [ z.object({ success: z.literal(true), metadata: FrontendWorkspaceMetadataSchema }), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 28959a8c75..7e06cf4877 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -160,9 +160,6 @@ export const WorkspaceMetadataSchema = z.object({ "When >1 entry, this is a multi-project workspace. " + "projectPath/projectName reflect the primary project for backcompat.", }), - sectionId: z.string().optional().meta({ - description: "ID of the section this workspace belongs to (optional, unsectioned if absent)", - }), }); export const FrontendWorkspaceMetadataSchema = WorkspaceMetadataSchema.extend({ diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index f992614990..4428d7acc5 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -183,9 +183,6 @@ export const WorkspaceConfigSchema = z.object({ "Durable restore metadata captured before archive-time worktree deletion. Present only while an archived snapshot is awaiting restore.", }), projects: z.array(ProjectRefSchema).optional(), - sectionId: z.string().optional().meta({ - description: "ID of the section this workspace belongs to (optional, unsectioned if absent)", - }), }); export const ProjectConfigSchema = z.object({ @@ -196,9 +193,6 @@ export const ProjectConfigSchema = z.object({ description: "Project folder accent color (hex value like #5a9bd4 or preset name)", }), workspaces: z.array(WorkspaceConfigSchema), - sections: z.array(SectionConfigSchema).optional().meta({ - description: "Sections for organizing workspaces within this project", - }), idleCompactionHours: z.number().min(1).nullable().optional().meta({ description: "Hours of inactivity before auto-compacting workspaces. null/undefined = disabled.", diff --git a/src/node/config.ts b/src/node/config.ts index 10aaf7766f..41f1811114 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1283,7 +1283,6 @@ export class Config { archivedAt: workspace.archivedAt, unarchivedAt: workspace.unarchivedAt, projects: workspaceProjects, - sectionId: workspace.sectionId, }; // Migrate missing createdAt to config for next load @@ -1381,8 +1380,6 @@ export class Config { // Preserve archived timestamps from config metadata.archivedAt ??= workspace.archivedAt; metadata.unarchivedAt ??= workspace.unarchivedAt; - // Preserve section assignment from config - metadata.sectionId ??= workspace.sectionId; metadata.forkFamilyBaseName ??= workspace.forkFamilyBaseName; if (!workspace.aiSettingsByAgent && metadata.aiSettingsByAgent) { @@ -1449,7 +1446,6 @@ export class Config { archivedAt: workspace.archivedAt, unarchivedAt: workspace.unarchivedAt, projects: workspaceProjects, - sectionId: workspace.sectionId, }; // Save to config for next load @@ -1497,7 +1493,6 @@ export class Config { taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, projects: workspaceProjects, - sectionId: workspace.sectionId, }; workspaceMetadata.push( @@ -1567,7 +1562,6 @@ export class Config { archivedAt: metadata.archivedAt, unarchivedAt: metadata.unarchivedAt, projects: metadata.projects, - sectionId: metadata.sectionId, }; if (existingIndex >= 0) { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index eb091a38a0..528197b2e7 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2871,54 +2871,6 @@ export const router = (authToken?: string) => { context.projectService.setIdleCompactionHours(input.projectPath, input.hours) ), }, - sections: { - list: t - .input(schemas.projects.sections.list.input) - .output(schemas.projects.sections.list.output) - .handler(({ context, input }) => context.projectService.listSections(input.projectPath)), - create: t - .input(schemas.projects.sections.create.input) - .output(schemas.projects.sections.create.output) - .handler(({ context, input }) => - context.projectService.createSection(input.projectPath, input.name, input.color) - ), - update: t - .input(schemas.projects.sections.update.input) - .output(schemas.projects.sections.update.output) - .handler(({ context, input }) => - context.projectService.updateSection(input.projectPath, input.sectionId, { - name: input.name, - color: input.color, - }) - ), - remove: t - .input(schemas.projects.sections.remove.input) - .output(schemas.projects.sections.remove.output) - .handler(({ context, input }) => - context.projectService.removeSection(input.projectPath, input.sectionId) - ), - reorder: t - .input(schemas.projects.sections.reorder.input) - .output(schemas.projects.sections.reorder.output) - .handler(({ context, input }) => - context.projectService.reorderSections(input.projectPath, input.sectionIds) - ), - assignWorkspace: t - .input(schemas.projects.sections.assignWorkspace.input) - .output(schemas.projects.sections.assignWorkspace.output) - .handler(async ({ context, input }) => { - const result = await context.projectService.assignWorkspaceToSection( - input.projectPath, - input.workspaceId, - input.sectionId - ); - if (result.success) { - // Emit metadata update so frontend receives the sectionId change - await context.workspaceService.refreshAndEmitMetadata(input.workspaceId); - } - return result; - }), - }, }, nameGeneration: { generate: t @@ -2993,8 +2945,7 @@ export const router = (authToken?: string) => { input.branchName, input.trunkBranch, input.title, - input.runtimeConfig, - input.sectionId + input.runtimeConfig ); if (!result.success) { return { success: false, error: result.error }; diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index ba7269df00..6939b45b2e 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -1,7 +1,4 @@ import type { Config, ProjectConfig } from "@/node/config"; -import type { SectionConfig } from "@/common/types/project"; -import { DEFAULT_SECTION_COLOR } from "@/common/constants/ui"; -import { sortSectionsByLinkedList } from "@/common/utils/sections"; import { formatSshEndpoint } from "@/common/utils/ssh/formatSshEndpoint"; import { spawn } from "child_process"; import { createHash, randomBytes } from "crypto"; @@ -1229,210 +1226,4 @@ export class ProjectService { return Err(`Failed to set idle compaction hours: ${message}`); } } - - // ───────────────────────────────────────────────────────────────────────────── - // Section Management - // ───────────────────────────────────────────────────────────────────────────── - - /** - * List all sections for a project, sorted by linked-list order. - */ - listSections(projectPath: string): SectionConfig[] { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - if (!project) return []; - return sortSectionsByLinkedList(project.sections ?? []); - } catch (error) { - log.error("Failed to list sections:", error); - return []; - } - } - - /** - * Create a new section in a project. - */ - async createSection( - projectPath: string, - name: string, - color?: string - ): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - - if (!project) { - return Err(`Project not found: ${projectPath}`); - } - - const sections = project.sections ?? []; - - const section: SectionConfig = { - id: randomBytes(4).toString("hex"), - name, - color: color ?? DEFAULT_SECTION_COLOR, - nextId: null, // new section is last - }; - - // Find current tail (nextId is null/undefined) and point it to new section - const sorted = sortSectionsByLinkedList(sections); - if (sorted.length > 0) { - const tail = sorted[sorted.length - 1]; - tail.nextId = section.id; - } - - project.sections = [...sections, section]; - await this.config.saveConfig(config); - return Ok(section); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to create section: ${message}`); - } - } - - /** - * Update section name and/or color. - */ - async updateSection( - projectPath: string, - sectionId: string, - updates: { name?: string; color?: string } - ): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - - if (!project) { - return Err(`Project not found: ${projectPath}`); - } - - const sections = project.sections ?? []; - const sectionIndex = sections.findIndex((s) => s.id === sectionId); - - if (sectionIndex === -1) { - return Err(`Section not found: ${sectionId}`); - } - - const section = sections[sectionIndex]; - if (updates.name !== undefined) section.name = updates.name; - if (updates.color !== undefined) section.color = updates.color; - - await this.config.saveConfig(config); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to update section: ${message}`); - } - } - - /** - * Remove a section and unsection any workspaces assigned to it. - */ - async removeSection(projectPath: string, sectionId: string): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - - if (!project) { - return Err(`Project not found: ${projectPath}`); - } - - const sections = project.sections ?? []; - const sectionIndex = sections.findIndex((s) => s.id === sectionId); - - if (sectionIndex === -1) { - return Err(`Section not found: ${sectionId}`); - } - - const workspacesInSection = project.workspaces.filter((w) => w.sectionId === sectionId); - - // Unsection all workspaces in this section - for (const workspace of workspacesInSection) { - workspace.sectionId = undefined; - } - - // Remove the section - project.sections = sections.filter((s) => s.id !== sectionId); - await this.config.saveConfig(config); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to remove section: ${message}`); - } - } - - /** - * Reorder sections by providing the full ordered list of section IDs. - */ - async reorderSections(projectPath: string, sectionIds: string[]): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - - if (!project) { - return Err(`Project not found: ${projectPath}`); - } - - const sections = project.sections ?? []; - const sectionMap = new Map(sections.map((s) => [s.id, s])); - - // Validate all IDs exist - for (const id of sectionIds) { - if (!sectionMap.has(id)) { - return Err(`Section not found: ${id}`); - } - } - - // Update nextId pointers based on array order - for (let i = 0; i < sectionIds.length; i++) { - const section = sectionMap.get(sectionIds[i])!; - section.nextId = i < sectionIds.length - 1 ? sectionIds[i + 1] : null; - } - - await this.config.saveConfig(config); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to reorder sections: ${message}`); - } - } - - /** - * Assign a workspace to a section (or remove from section with null). - */ - async assignWorkspaceToSection( - projectPath: string, - workspaceId: string, - sectionId: string | null - ): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const project = config.projects.get(projectPath); - - if (!project) { - return Err(`Project not found: ${projectPath}`); - } - - // Validate section exists if not null - if (sectionId !== null) { - const sections = project.sections ?? []; - if (!sections.some((s) => s.id === sectionId)) { - return Err(`Section not found: ${sectionId}`); - } - } - - // Find and update workspace - const workspace = project.workspaces.find((w) => w.id === workspaceId); - if (!workspace) { - return Err(`Workspace not found: ${workspaceId}`); - } - - workspace.sectionId = sectionId ?? undefined; - await this.config.saveConfig(config); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to assign workspace to section: ${message}`); - } - } } diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index f8a083adfb..8870943552 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -171,6 +172,10 @@ describe("buildSystemMessage", () => { }; } + function initGitRepo(repoPath: string): void { + execFileSync("git", ["-C", repoPath, "init", "-b", "main"]); + } + afterEach(async () => { // Clean up temp directory await fs.rm(tempDir, { recursive: true, force: true }); @@ -210,6 +215,68 @@ Use clear examples. expect(customInstructions).toContain("Use clear examples."); }); + test("concatenates parent and child instructions for nested sub-projects", async () => { + const childProjectDir = path.join(projectDir, "packages", "payments"); + await fs.mkdir(childProjectDir, { recursive: true }); + initGitRepo(projectDir); + + await fs.writeFile(path.join(projectDir, "AGENTS.md"), "Root instructions."); + await fs.writeFile(path.join(childProjectDir, "AGENTS.md"), "Child instructions."); + + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "payments", + projectPath: childProjectDir, + runtimeConfig: { type: "local" }, + }; + + const systemMessage = await buildSystemMessage(metadata, runtime, childProjectDir); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("Root instructions."); + expect(customInstructions).toContain("Child instructions."); + expect(customInstructions.indexOf("Root instructions.")).toBeLessThan( + customInstructions.indexOf("Child instructions.") + ); + }); + + test("preserves tool instructions from every nested sub-project instruction file", async () => { + const childProjectDir = path.join(projectDir, "packages", "payments"); + await fs.mkdir(childProjectDir, { recursive: true }); + initGitRepo(projectDir); + + await fs.writeFile( + path.join(projectDir, "AGENTS.md"), + "## Tool: bash\nFrom root: start with git status --short.\n" + ); + await fs.writeFile( + path.join(childProjectDir, "AGENTS.md"), + "## Tool: bash\nFrom child: prefer bun test --watch.\n" + ); + + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "payments", + projectPath: childProjectDir, + runtimeConfig: { type: "local" }, + }; + + const toolInstructions = await readToolInstructions( + metadata, + runtime, + childProjectDir, + "anthropic:claude-sonnet-4-20250514" + ); + + expect(toolInstructions.bash).toBe( + ["From root: start with git status --short.", "From child: prefer bun test --watch."].join( + "\n\n" + ) + ); + }); + test("includes generic instructions from every project repo in a multi-project workspace", async () => { const { metadata, primaryWorkspaceRepoDir, secondaryWorkspaceRepoDir } = await createMultiProjectFixture(); diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 15f4ce179e..191c9ccfae 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -1,9 +1,10 @@ +import * as fs from "node:fs/promises"; import path from "node:path"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { MCPServerMap } from "@/common/types/mcp"; import type { RuntimeMode } from "@/common/types/runtime"; -import { RUNTIME_MODE } from "@/common/types/runtime"; +import { RUNTIME_MODE, isLocalProjectRuntime } from "@/common/types/runtime"; import { getProjects, isMultiProject } from "@/common/utils/multiProject"; import { readInstructionSet, @@ -310,6 +311,94 @@ export async function readToolInstructions( }); } +async function resolveGitTopLevel(projectPath: string): Promise { + let currentDirectory = path.resolve(projectPath); + + while (true) { + try { + await fs.access(path.join(currentDirectory, ".git")); + return currentDirectory; + } catch { + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } + } +} + +function buildInstructionHierarchy(projectRootPath: string, projectPath: string): string[] { + const normalizedRoot = path.resolve(projectRootPath); + const normalizedProjectPath = path.resolve(projectPath); + const relativeProjectPath = path.relative(normalizedRoot, normalizedProjectPath); + + if (relativeProjectPath.length === 0) { + return [normalizedRoot]; + } + + if (relativeProjectPath.startsWith("..") || path.isAbsolute(relativeProjectPath)) { + return [normalizedProjectPath]; + } + + const segments = relativeProjectPath.split(path.sep).filter((segment) => segment.length > 0); + return [ + normalizedRoot, + ...segments.map((_, index) => path.join(normalizedRoot, ...segments.slice(0, index + 1))), + ]; +} + +async function readInstructionHierarchy( + runtime: Runtime, + workspaceProjectRootPath: string | null, + projectPath: string +): Promise { + const projectRootPath = (await resolveGitTopLevel(projectPath)) ?? projectPath; + const hierarchy = buildInstructionHierarchy(projectRootPath, projectPath); + const segments: string[] = []; + + for (const localDirectory of hierarchy) { + if (!workspaceProjectRootPath) { + const localInstructions = await readInstructionSet(localDirectory); + if (localInstructions) { + segments.push(localInstructions); + } + continue; + } + + const relativeProjectPath = path.relative(projectRootPath, localDirectory); + const runtimeDirectory = + relativeProjectPath.length === 0 + ? workspaceProjectRootPath + : path.join(workspaceProjectRootPath, relativeProjectPath); + const runtimeInstructions = await readInstructionSetFromRuntime(runtime, runtimeDirectory); + if (runtimeInstructions) { + segments.push(runtimeInstructions); + continue; + } + + const localInstructions = await readInstructionSet(localDirectory); + if (localInstructions) { + segments.push(localInstructions); + } + } + + return segments; +} + +async function readSingleProjectContextInstructions( + metadata: WorkspaceMetadata, + runtime: Runtime, + workspacePath: string +): Promise { + const projectInstructionRoots = await readInstructionHierarchy( + runtime, + isLocalProjectRuntime(metadata.runtimeConfig) ? null : workspacePath, + metadata.projectPath + ); + return projectInstructionRoots.length > 0 ? projectInstructionRoots.join("\n\n") : null; +} + async function readMultiProjectContextInstructions( metadata: WorkspaceMetadata, runtime: Runtime, @@ -333,13 +422,13 @@ async function readMultiProjectContextInstructions( ); seenProjectNames.add(project.projectName); - const workspaceProjectPath = path.join(workspacePath, project.projectName); - const projectInstructions = - (await readInstructionSetFromRuntime(runtime, workspaceProjectPath)) ?? - (await readInstructionSet(project.projectPath)); - if (projectInstructions) { - contextSegments.push(projectInstructions); - } + const workspaceProjectRootPath = path.join(workspacePath, project.projectName); + const projectInstructions = await readInstructionHierarchy( + runtime, + workspaceProjectRootPath, + project.projectPath + ); + contextSegments.push(...projectInstructions); } return contextSegments.length > 0 ? contextSegments.join("\n\n") : null; @@ -349,9 +438,9 @@ async function readMultiProjectContextInstructions( * Read instruction sets from global and context sources. * Internal helper for buildSystemMessage and extractToolInstructions. * - * Single-project workspaces keep the historical lookup order of workspace root → project root. - * Multi-project workspaces layer the shared container instructions with every per-project repo - * mounted under / so secondary repos can contribute scoped instructions. + * Single-project workspaces concatenate repo-level instructions from parent to child so nested + * sub-projects inherit every AGENTS.md along the path. Multi-project workspaces also include the + * shared container instructions plus each mounted project's own parent→child instruction chain. * * @param metadata - Workspace metadata (contains projectPath) * @param runtime - Runtime for reading workspace files (supports SSH) @@ -366,8 +455,7 @@ async function readInstructionSources( const globalInstructions = await readInstructionSet(getSystemDirectory()); const contextInstructions = isMultiProject(metadata) ? await readMultiProjectContextInstructions(metadata, runtime, workspacePath) - : ((await readInstructionSetFromRuntime(runtime, workspacePath)) ?? - (await readInstructionSet(metadata.projectPath))); + : await readSingleProjectContextInstructions(metadata, runtime, workspacePath); return [globalInstructions, contextInstructions]; } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c06c5a395e..7617e1e257 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2120,8 +2120,7 @@ export class WorkspaceService extends EventEmitter { branchName: string, trunkBranch: string | undefined, title?: string, - runtimeConfig?: RuntimeConfig, - sectionId?: string + runtimeConfig?: RuntimeConfig ): Promise> { // Validate workspace name const validation = validateWorkspaceName(branchName); @@ -2303,7 +2302,6 @@ export class WorkspaceService extends EventEmitter { title, createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, - sectionId, }); return config; }); @@ -5246,8 +5244,6 @@ export class WorkspaceService extends EventEmitter { createdAt: new Date().toISOString(), runtimeConfig: forkedRuntimeConfig, namedWorkspacePath, - // Preserve workspace organization when forking via /fork. - sectionId: sourceMetadata.sectionId, // Forks with a continue message stay pending until the first accepted user send // can generate a more specific title, unless the user edits the title first. pendingAutoTitle: pendingAutoTitle === true ? true : undefined, diff --git a/tests/ui/chat/sections.test.ts b/tests/ui/chat/sections.test.ts deleted file mode 100644 index a75f773830..0000000000 --- a/tests/ui/chat/sections.test.ts +++ /dev/null @@ -1,774 +0,0 @@ -/** - * Integration tests for workspace sections. - * - * Tests verify: - * - Section UI elements render correctly with proper data attributes - * - Section and drop zone UI elements render with proper data attributes - * - Workspace creation with sectionId assigns to that section - * - Section "+" button pre-selects section in creation flow - * - Section removal invariants (removal unsections active/archived workspaces) - * - Section reordering via API and UI reflection - * - * Testing approach: - * - Section creation uses ORPC (happy-dom doesn't reliably handle React controlled inputs) - * - We test that sections render correctly, not the text input submission interaction - * - Workspace creation uses ORPC for speed (setup/teardown is acceptable per AGENTS.md) - * - DnD gestures tested in Storybook (react-dnd-html5-backend doesn't work in happy-dom) - */ - -import "../dom"; -import { act, fireEvent, waitFor } from "@testing-library/react"; - -import { shouldRunIntegrationTests } from "../../testUtils"; -import { - cleanupSharedRepo, - createSharedRepo, - getSharedEnv, - getSharedRepoPath, -} from "../../ipc/sendMessageTestHelpers"; -import { generateBranchName } from "../../ipc/helpers"; -import { detectDefaultTrunkBranch } from "../../../src/node/git"; - -import { installDom } from "../dom"; -import { renderApp } from "../renderReviewPanel"; -import { cleanupView, setupWorkspaceView } from "../helpers"; -import { expandProjects } from "@/browser/stories/helpers/uiState"; - -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -// ═══════════════════════════════════════════════════════════════════════════════ -// HELPER FUNCTIONS -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Find a workspace row in the sidebar by workspace ID. - */ -function findWorkspaceRow(container: HTMLElement, workspaceId: string): HTMLElement | null { - return container.querySelector(`[data-workspace-id="${workspaceId}"]`); -} - -/** - * Find a section drop zone in the sidebar by section ID. - */ -function findSectionDropZone(container: HTMLElement, sectionId: string): HTMLElement | null { - return container.querySelector(`[data-drop-section-id="${sectionId}"]`); -} - -/** - * Find the unsectioned workspaces drop zone. - */ -function findUnsectionedDropZone(container: HTMLElement): HTMLElement | null { - return container.querySelector('[data-testid="unsectioned-drop-zone"]'); -} - -/** - * Wait for a section header to appear in the sidebar. - */ -async function waitForSection( - container: HTMLElement, - sectionId: string, - timeoutMs = 5_000 -): Promise { - return waitFor( - () => { - const section = container.querySelector(`[data-section-id="${sectionId}"]`); - if (!section) throw new Error(`Section ${sectionId} not found`); - return section as HTMLElement; - }, - { timeout: timeoutMs } - ); -} - -/** - * Get all section IDs in DOM order. - */ -function getSectionIdsInOrder(container: HTMLElement): string[] { - const sections = container.querySelectorAll("[data-section-id]"); - return Array.from(sections) - .map((el) => el.getAttribute("data-section-id")) - .filter((id): id is string => id !== null && id !== ""); -} - -/** - * Create a section via ORPC. Returns the section ID. - * - * Note: This does NOT wait for UI to update - use with tests that don't need - * immediate UI reflection, or call refreshProjects() after and wait appropriately. - * - * We use ORPC instead of UI interactions because happy-dom doesn't properly - * handle React controlled inputs (fireEvent.change doesn't trigger React state updates - * synchronously, causing keyDown/blur handlers to see stale state). - */ -async function createSectionViaAPI( - env: ReturnType, - projectPath: string, - sectionName: string -): Promise { - const result = await env.orpc.projects.sections.create({ - projectPath, - name: sectionName, - }); - - if (!result.success) { - throw new Error(`Failed to create section: ${result.error}`); - } - - return result.data.id; -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// TESTS -// ═══════════════════════════════════════════════════════════════════════════════ - -describeIntegration("Workspace Sections", () => { - beforeAll(async () => { - await createSharedRepo(); - }); - - afterAll(async () => { - await cleanupSharedRepo(); - }); - - // ───────────────────────────────────────────────────────────────────────────── - // UI Infrastructure - // ───────────────────────────────────────────────────────────────────────────── - - test("section renders with drop zones after creation", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - - // Create a workspace first (ORPC is fine for setup) - const branchName = generateBranchName("test-section-ui"); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - const wsResult = await env.orpc.workspace.create({ - projectPath, - branchName, - trunkBranch, - }); - if (!wsResult.success) throw new Error(`Failed to create workspace: ${wsResult.error}`); - const workspaceId = wsResult.metadata.id; - const metadata = wsResult.metadata; - - // Create section BEFORE rendering so it's in the initial config - const sectionId = await createSectionViaAPI(env, projectPath, "Test Section"); - - const cleanupDom = installDom(); - expandProjects([projectPath]); - - const view = renderApp({ apiClient: env.orpc, metadata }); - - try { - await setupWorkspaceView(view, metadata, workspaceId); - - // Wait for section to appear in UI - await waitForSection(view.container, sectionId); - - // Verify section drop zone exists (for workspace drag-drop) - const sectionDropZone = findSectionDropZone(view.container, sectionId); - expect(sectionDropZone).not.toBeNull(); - - // Verify unsectioned drop zone exists when sections are present - const unsectionedZone = findUnsectionedDropZone(view.container); - expect(unsectionedZone).not.toBeNull(); - - // Verify workspace row exists and has data-section-id attribute - const workspaceRow = findWorkspaceRow(view.container, workspaceId); - expect(workspaceRow).not.toBeNull(); - expect(workspaceRow!.hasAttribute("data-section-id")).toBe(true); - - // Verify section has drag-related attribute for reordering - const sectionDragWrapper = view.container.querySelector( - `[data-section-drag-id="${sectionId}"]` - ); - expect(sectionDragWrapper).not.toBeNull(); - } finally { - await cleanupView(view, cleanupDom); - await env.orpc.workspace.remove({ workspaceId }); - await env.orpc.projects.sections.remove({ projectPath, sectionId }); - } - }, 60_000); - - // ───────────────────────────────────────────────────────────────────────────── - // Workspace Creation with Section - // ───────────────────────────────────────────────────────────────────────────── - - test("workspace created with sectionId is assigned to that section", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create workspace without section first to ensure project exists - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - const sectionResult = await env.orpc.projects.sections.create({ - projectPath, - name: "Target Section", - }); - if (!sectionResult.success) throw new Error(`Failed to create section: ${sectionResult.error}`); - const sectionId = sectionResult.data.id; - - let workspaceId: string | undefined; - try { - // Create workspace WITH sectionId - const wsResult = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("test-create-in-section"), - trunkBranch, - sectionId, - }); - if (!wsResult.success) throw new Error(`Failed to create workspace: ${wsResult.error}`); - workspaceId = wsResult.metadata.id; - - // Verify workspace metadata has the sectionId - const workspaceInfo = await env.orpc.workspace.getInfo({ workspaceId }); - expect(workspaceInfo?.sectionId).toBe(sectionId); - } finally { - if (workspaceId) await env.orpc.workspace.remove({ workspaceId }); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId }); - } - }, 60_000); - - test("clicking section add button sets pending section for creation", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create workspace to ensure project exists (ORPC for setup is acceptable) - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-section-add"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create section BEFORE rendering so it's in the initial config - const sectionId = await createSectionViaAPI(env, projectPath, "Add Button Section"); - - const cleanupDom = installDom(); - expandProjects([projectPath]); - - const view = renderApp({ apiClient: env.orpc, metadata: setupWs.metadata }); - - try { - await setupWorkspaceView(view, setupWs.metadata, setupWs.metadata.id); - - // Wait for section to render - await waitForSection(view.container, sectionId); - - // Find the "+" button in the section header - const sectionHeader = view.container.querySelector(`[data-section-id="${sectionId}"]`); - expect(sectionHeader).not.toBeNull(); - - const addButton = sectionHeader!.querySelector('button[aria-label="New chat in section"]'); - expect(addButton).not.toBeNull(); - - // Click the add button - this should navigate to create page with section context - // Wrap in act() to ensure React state updates are properly flushed - await act(async () => { - fireEvent.click(addButton as HTMLElement); - }); - - // Wait for the create page to show section selector with this section pre-selected - await waitFor( - () => { - const sectionSelector = view.container.querySelector('[data-testid="section-selector"]'); - if (!sectionSelector) { - throw new Error("Section selector not found on create page"); - } - const selectedValue = sectionSelector.getAttribute("data-selected-section"); - if (selectedValue !== sectionId) { - throw new Error(`Expected section ${sectionId} to be selected, got ${selectedValue}`); - } - }, - { timeout: 5_000 } - ); - - // The creation UI should allow clearing the selection (return to unsectioned). - const sectionSelector = view.container.querySelector('[data-testid="section-selector"]'); - if (!sectionSelector) { - throw new Error("Section selector not found on create page (post-selection)"); - } - - const clearButton = sectionSelector.querySelector( - 'button[aria-label="Clear section selection"]' - ); - expect(clearButton).not.toBeNull(); - - await act(async () => { - fireEvent.click(clearButton as HTMLElement); - }); - - await waitFor(() => { - expect(sectionSelector.getAttribute("data-selected-section")).toBe(""); - }); - } finally { - await cleanupView(view, cleanupDom); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId }); - } - }, 60_000); - - test("fork API preserves section assignment", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a workspace first to ensure the project is registered. - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-fork-section"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create a section and a workspace inside it. - const sectionId = await createSectionViaAPI(env, projectPath, "Fork Section"); - - let sourceWorkspaceId: string | undefined; - let forkedWorkspaceId: string | undefined; - - try { - const sourceWsResult = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("fork-section-source"), - trunkBranch, - sectionId, - }); - if (!sourceWsResult.success) { - throw new Error(`Failed to create source workspace: ${sourceWsResult.error}`); - } - - sourceWorkspaceId = sourceWsResult.metadata.id; - - const forkedName = generateBranchName("forked-in-section"); - const forkResult = await env.orpc.workspace.fork({ - sourceWorkspaceId, - newName: forkedName, - }); - if (!forkResult.success) { - throw new Error(`Failed to fork workspace: ${forkResult.error}`); - } - - forkedWorkspaceId = forkResult.metadata.id; - expect(forkResult.metadata.sectionId).toBe(sectionId); - } finally { - // Best-effort cleanup: remove any workspaces still assigned to this section, - // even if the assertion failed before we captured forkedWorkspaceId. - const activeWorkspaces = await env.orpc.workspace.list(); - const sectionWorkspaceIds = activeWorkspaces - .filter((workspace) => workspace.sectionId === sectionId) - .map((workspace) => workspace.id); - - if (forkedWorkspaceId) { - sectionWorkspaceIds.push(forkedWorkspaceId); - } - if (sourceWorkspaceId) { - sectionWorkspaceIds.push(sourceWorkspaceId); - } - - const uniqueWorkspaceIds = [...new Set(sectionWorkspaceIds)].filter( - (workspaceId) => workspaceId !== setupWs.metadata.id - ); - - for (const workspaceId of uniqueWorkspaceIds) { - await env.orpc.workspace.remove({ workspaceId }).catch(() => {}); - } - - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }).catch(() => {}); - await env.orpc.projects.sections.remove({ projectPath, sectionId }).catch(() => {}); - } - }, 60_000); - // ───────────────────────────────────────────────────────────────────────────── - // Section Reordering - // ───────────────────────────────────────────────────────────────────────────── - - test("reorderSections API updates section order", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a workspace to ensure project exists - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-reorder-api"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create three sections (they'll be in creation order: A, B, C) - const sectionA = await env.orpc.projects.sections.create({ - projectPath, - name: "Section A", - }); - if (!sectionA.success) throw new Error(`Failed to create section: ${sectionA.error}`); - - const sectionB = await env.orpc.projects.sections.create({ - projectPath, - name: "Section B", - }); - if (!sectionB.success) throw new Error(`Failed to create section: ${sectionB.error}`); - - const sectionC = await env.orpc.projects.sections.create({ - projectPath, - name: "Section C", - }); - if (!sectionC.success) throw new Error(`Failed to create section: ${sectionC.error}`); - - try { - // Verify initial order for the sections created in this test. - let sections = await env.orpc.projects.sections.list({ projectPath }); - const trackedSectionIds = [sectionA.data.id, sectionB.data.id, sectionC.data.id]; - const trackedInitialOrder = sections - .filter((section) => trackedSectionIds.includes(section.id)) - .map((section) => section.name); - expect(trackedInitialOrder).toEqual(["Section A", "Section B", "Section C"]); - - // Reorder to C, A, B - const reorderResult = await env.orpc.projects.sections.reorder({ - projectPath, - sectionIds: [sectionC.data.id, sectionA.data.id, sectionB.data.id], - }); - expect(reorderResult.success).toBe(true); - - // Verify new order for the sections created in this test. - sections = await env.orpc.projects.sections.list({ projectPath }); - const trackedReordered = sections - .filter((section) => trackedSectionIds.includes(section.id)) - .map((section) => section.name); - expect(trackedReordered).toEqual(["Section C", "Section A", "Section B"]); - } finally { - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId: sectionA.data.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId: sectionB.data.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId: sectionC.data.id }); - } - }, 60_000); - - // Note: UI auto-refresh after reorder requires the full DnD flow which triggers - // ProjectContext.reorderSections -> refreshProjects(). Direct API calls bypass this. - // The sorting logic is unit-tested in workspaceFiltering.test.ts (sortSectionsByLinkedList). - // This test verifies initial render respects section order from backend. - test("sections render in linked-list order from config", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a workspace to ensure project exists - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-section-order"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create two sections (will be in creation order: First, Second) - const sectionFirst = await env.orpc.projects.sections.create({ - projectPath, - name: "First Section", - }); - if (!sectionFirst.success) throw new Error(`Failed to create section: ${sectionFirst.error}`); - - const sectionSecond = await env.orpc.projects.sections.create({ - projectPath, - name: "Second Section", - }); - if (!sectionSecond.success) throw new Error(`Failed to create section: ${sectionSecond.error}`); - - const cleanupDom = installDom(); - expandProjects([projectPath]); - - const view = renderApp({ apiClient: env.orpc, metadata: setupWs.metadata }); - - try { - await setupWorkspaceView(view, setupWs.metadata, setupWs.metadata.id); - - // Wait for sections to appear - await waitForSection(view.container, sectionFirst.data.id); - await waitForSection(view.container, sectionSecond.data.id); - - // Verify DOM order matches linked-list order (First -> Second) for the - // sections created in this test. Other sections may exist from unrelated setup. - const orderedIds = getSectionIdsInOrder(view.container); - const trackedOrder = orderedIds.filter( - (id) => id === sectionFirst.data.id || id === sectionSecond.data.id - ); - expect(trackedOrder).toEqual([sectionFirst.data.id, sectionSecond.data.id]); - } finally { - await cleanupView(view, cleanupDom); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId: sectionFirst.data.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId: sectionSecond.data.id }); - } - }, 60_000); - - // ───────────────────────────────────────────────────────────────────────────── - // Section Removal Invariants - // ───────────────────────────────────────────────────────────────────────────── - - test("removing section clears sectionId from active workspaces", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a setup workspace first to ensure project is registered - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-removal"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create a section - const sectionResult = await env.orpc.projects.sections.create({ - projectPath, - name: `test-section-${Date.now()}`, - }); - expect(sectionResult.success).toBe(true); - const sectionId = sectionResult.success ? sectionResult.data.id : ""; - - // Create a workspace in that section - const wsResult = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("section-removal-test"), - trunkBranch, - sectionId, - }); - expect(wsResult.success).toBe(true); - const workspaceId = wsResult.success ? wsResult.metadata.id : ""; - - try { - // Verify workspace starts sectioned - let wsInfo = await env.orpc.workspace.getInfo({ workspaceId }); - expect(wsInfo).not.toBeNull(); - expect(wsInfo?.sectionId).toBe(sectionId); - - // Remove section with active workspaces - should succeed and unsection the workspace - const removeResult = await env.orpc.projects.sections.remove({ - projectPath, - sectionId, - }); - expect(removeResult.success).toBe(true); - - // Verify section was removed - const sections = await env.orpc.projects.sections.list({ projectPath }); - expect(sections.some((section) => section.id === sectionId)).toBe(false); - - // Verify workspace's sectionId is now cleared - wsInfo = await env.orpc.workspace.getInfo({ workspaceId }); - expect(wsInfo).not.toBeNull(); - expect(wsInfo?.sectionId).toBeUndefined(); - } finally { - await env.orpc.workspace.remove({ workspaceId }); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId }).catch(() => {}); - } - }, 30_000); - - test("removing section clears sectionId from archived workspaces", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a setup workspace first to ensure project is registered - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-archive"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create a section - const sectionResult = await env.orpc.projects.sections.create({ - projectPath, - name: `test-section-archive-${Date.now()}`, - }); - expect(sectionResult.success).toBe(true); - const sectionId = sectionResult.success ? sectionResult.data.id : ""; - - // Create a workspace in that section - const wsResult = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("archive-section-test"), - trunkBranch, - sectionId, - }); - expect(wsResult.success).toBe(true); - const workspaceId = wsResult.success ? wsResult.metadata.id : ""; - - try { - // Archive the workspace - const archiveResult = await env.orpc.workspace.archive({ workspaceId }); - expect(archiveResult.success).toBe(true); - - // Verify workspace is archived and has sectionId - let wsInfo = await env.orpc.workspace.getInfo({ workspaceId }); - expect(wsInfo).not.toBeNull(); - expect(wsInfo?.sectionId).toBe(sectionId); - expect(wsInfo?.archivedAt).toBeDefined(); - - // Now remove the section - should succeed since workspace is archived - const removeResult = await env.orpc.projects.sections.remove({ - projectPath, - sectionId, - }); - expect(removeResult.success).toBe(true); - - // Verify workspace's sectionId is now cleared - wsInfo = await env.orpc.workspace.getInfo({ workspaceId }); - expect(wsInfo).not.toBeNull(); - expect(wsInfo?.sectionId).toBeUndefined(); - } finally { - await env.orpc.workspace.remove({ workspaceId }); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - // Section already removed in test, but try anyway in case test failed early - await env.orpc.projects.sections.remove({ projectPath, sectionId }).catch(() => {}); - } - }, 30_000); - - // ───────────────────────────────────────────────────────────────────────────── - // Section Deletion Confirmation Flow - // ───────────────────────────────────────────────────────────────────────────── - - test("clicking delete on section with active workspaces confirms and unsections workspaces", async () => { - const env = getSharedEnv(); - const projectPath = getSharedRepoPath(); - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - // Create a setup workspace first to ensure project is registered - const setupWs = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("setup-delete-confirm"), - trunkBranch, - }); - if (!setupWs.success) throw new Error(`Setup failed: ${setupWs.error}`); - - // Create a section - const sectionResult = await env.orpc.projects.sections.create({ - projectPath, - name: `test-delete-confirm-${Date.now()}`, - }); - expect(sectionResult.success).toBe(true); - const sectionId = sectionResult.success ? sectionResult.data.id : ""; - - // Create a workspace in that section (active, not archived) - const wsResult = await env.orpc.workspace.create({ - projectPath, - branchName: generateBranchName("in-section-delete-confirm"), - trunkBranch, - sectionId, - }); - expect(wsResult.success).toBe(true); - const workspaceId = wsResult.success ? wsResult.metadata.id : ""; - const metadata = wsResult.success ? wsResult.metadata : setupWs.metadata; - - const cleanupDom = installDom(); - expandProjects([projectPath]); - - const view = renderApp({ apiClient: env.orpc, metadata }); - - try { - await setupWorkspaceView(view, metadata, workspaceId); - - // Wait for section and workspace to appear in UI as sectioned - await waitForSection(view.container, sectionId); - const workspaceRowBeforeDelete = findWorkspaceRow(view.container, workspaceId); - expect(workspaceRowBeforeDelete).not.toBeNull(); - expect(workspaceRowBeforeDelete?.getAttribute("data-section-id")).toBe(sectionId); - - // Open section actions and click Delete section - const sectionElement = view.container.querySelector(`[data-section-id="${sectionId}"]`); - expect(sectionElement).not.toBeNull(); - - // Hover over section to reveal action controls. - fireEvent.mouseEnter(sectionElement!); - - const sectionActionsButton = sectionElement!.querySelector('[aria-label="Section actions"]'); - expect(sectionActionsButton).not.toBeNull(); - fireEvent.click(sectionActionsButton!); - - const deleteButton = await waitFor( - () => { - const buttons = Array.from(view.container.ownerDocument.body.querySelectorAll("button")); - const button = buttons.find((candidate) => - candidate.textContent?.trim().startsWith("Delete section") - ); - if (!button) throw new Error("Delete section menu item not found"); - return button as HTMLButtonElement; - }, - { timeout: 5_000 } - ); - expect(deleteButton).not.toBeNull(); - fireEvent.click(deleteButton); - - // Confirm the deletion warning for active workspaces - const confirmDialog = await waitFor( - () => { - const dialog = view.container.ownerDocument.body.querySelector( - '[role="dialog"], [role="alertdialog"]' - ); - if (!dialog) throw new Error("Delete confirmation dialog not found"); - - const dialogText = dialog.textContent ?? ""; - if (!dialogText.includes("Delete section?")) { - throw new Error(`Expected delete confirmation title, got: ${dialogText}`); - } - if (!dialogText.includes("will be moved to unsectioned")) { - throw new Error(`Expected unsection warning, got: ${dialogText}`); - } - - return dialog as HTMLElement; - }, - { timeout: 5_000 } - ); - - const confirmDeleteButton = Array.from(confirmDialog.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Delete") - ); - if (!confirmDeleteButton) { - throw new Error("Delete confirmation button not found"); - } - fireEvent.click(confirmDeleteButton); - - // Section should be removed from UI - await waitFor( - () => { - const removedSection = view.container.querySelector(`[data-section-id="${sectionId}"]`); - if (removedSection) throw new Error("Section was not removed from the sidebar"); - }, - { timeout: 5_000 } - ); - - // Workspace should remain but become unsectioned - await waitFor( - () => { - const workspaceRow = findWorkspaceRow(view.container, workspaceId); - if (!workspaceRow) throw new Error("Workspace row not found after deleting section"); - - const updatedSectionId = workspaceRow.getAttribute("data-section-id"); - if (updatedSectionId !== "") { - throw new Error( - `Expected workspace to be unsectioned, got data-section-id=${updatedSectionId}` - ); - } - }, - { timeout: 5_000 } - ); - - // Backend should reflect the unsectioned workspace as well - const wsInfoAfterDelete = await env.orpc.workspace.getInfo({ workspaceId }); - expect(wsInfoAfterDelete).not.toBeNull(); - expect(wsInfoAfterDelete?.sectionId).toBeUndefined(); - } finally { - await cleanupView(view, cleanupDom); - await env.orpc.workspace.remove({ workspaceId }); - await env.orpc.workspace.remove({ workspaceId: setupWs.metadata.id }); - await env.orpc.projects.sections.remove({ projectPath, sectionId }).catch(() => {}); - } - }, 60_000); -}); From 708ed7d73af3a26bf69be1e2f3e9a36944e6d3df Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 6 Apr 2026 18:14:49 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20fix=20storybook=20i?= =?UTF-8?q?nteraction=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Storybook interaction stories to stop relying on the removed section selector and to avoid ambiguous text assertions in immersive review coverage. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `n/a`_ --- .../ProjectPage/ProjectPage.stories.tsx | 49 +++++-------------- .../ImmersiveReviewView.stories.tsx | 4 +- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/src/browser/components/ProjectPage/ProjectPage.stories.tsx b/src/browser/components/ProjectPage/ProjectPage.stories.tsx index b2fdcb689e..aaffb1ea3a 100644 --- a/src/browser/components/ProjectPage/ProjectPage.stories.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.stories.tsx @@ -133,9 +133,7 @@ export const CreateWorkspaceMultipleProjects: AppStory = { }; /** - * Creation view with project sections configured. - * On desktop the section selector is inline / right-aligned in the header row. - * On mobile it drops to its own row below the header. + * Creation view for a project opened from the sidebar on desktop and mobile. * * Includes mobile chromatic modes: the sidebar starts expanded via * localStorage so the play function can click the project row, then @@ -160,19 +158,7 @@ export const CreateWorkspaceWithSections: AppStory = { // the project row even in mobile viewport modes. localStorage.setItem(LEFT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(false)); return createMockORPCClient({ - projects: new Map([ - [ - "/Users/dev/my-project", - { - workspaces: [], - sections: [ - { id: "sec_0001", name: "Frontend", color: "#4f8cf7", nextId: "sec_0002" }, - { id: "sec_0002", name: "Backend", color: "#f76b4f", nextId: "sec_0003" }, - { id: "sec_0003", name: "Infra", color: "#8b5cf6", nextId: null }, - ], - }, - ], - ]), + projects: new Map([["/Users/dev/my-project", { workspaces: [] }]]), workspaces: [], }); }} @@ -198,34 +184,21 @@ export const CreateWorkspaceWithSections: AppStory = { } } - // Wait for the section selector to be visible. Two instances exist in - // the DOM (one for desktop inline, one for mobile own-row via - // hidden/md:hidden). Find the one that's actually rendered. await waitFor( () => { - const allSelectors = storyRoot.querySelectorAll( - "[data-testid='section-selector']" + const headerRow = storyRoot.querySelector( + "[data-component='WorkspaceNameGroup']" ); - const sectionSelector = Array.from(allSelectors).find( - (el) => el.offsetWidth > 0 && el.offsetHeight > 0 - ); - if (!sectionSelector) { - throw new Error("Section selector not visible"); + if (!headerRow) { + throw new Error("Workspace name header row not found"); } - // On narrow viewports, verify the section selector sits below - // the header row (mobile-only own-row layout). if (window.innerWidth < 768) { - const headerRow = storyRoot.querySelector("[data-component='WorkspaceNameGroup']"); - if (!headerRow) { - throw new Error("Workspace name header row not found"); - } - const headerBottom = headerRow.getBoundingClientRect().bottom; - const sectionTop = sectionSelector.getBoundingClientRect().top; - if (sectionTop < headerBottom) { - throw new Error( - `Section selector overlaps header row (section top=${sectionTop}, header bottom=${headerBottom})` - ); + const runtimeGroup = storyRoot.querySelector( + "[data-component='RuntimeTypeGroup']" + ); + if (!runtimeGroup) { + throw new Error("Runtime controls not found"); } } }, diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx index d019009b4f..da0be41362 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx @@ -430,7 +430,9 @@ export const ImmersiveNotesSidebarActionFooter: Story = { await waitFor( () => { canvas.getByTestId("immersive-review-view"); - canvas.getByText(/Keep the formatter instance shared/i); + if (canvas.getAllByText(/Keep the formatter instance shared/i).length === 0) { + throw new Error("Expected the immersive review note text to render."); + } }, { timeout: 10_000 } ); From 10609c95532161be779d6cd75eb79687af94e88d Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 6 Apr 2026 18:19:04 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20workspace=20AG?= =?UTF-8?q?ENTS=20precedence=20for=20local=20runtimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve workspace-first instruction loading for local single-project workspaces while still layering parent-to-child AGENTS directories for nested sub-projects. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `n/a`_ --- src/node/services/systemMessage.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 191c9ccfae..a91203a527 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -391,9 +391,35 @@ async function readSingleProjectContextInstructions( runtime: Runtime, workspacePath: string ): Promise { + if (isLocalProjectRuntime(metadata.runtimeConfig)) { + const projectRootPath = + (await resolveGitTopLevel(metadata.projectPath)) ?? metadata.projectPath; + const hierarchy = buildInstructionHierarchy(projectRootPath, metadata.projectPath); + const contextSegments: string[] = []; + + for (const localDirectory of hierarchy.slice(0, -1)) { + const localInstructions = await readInstructionSet(localDirectory); + if (localInstructions) { + contextSegments.push(localInstructions); + } + } + + const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); + if (workspaceInstructions) { + contextSegments.push(workspaceInstructions); + } else { + const leafProjectInstructions = await readInstructionSet(hierarchy[hierarchy.length - 1]); + if (leafProjectInstructions) { + contextSegments.push(leafProjectInstructions); + } + } + + return contextSegments.length > 0 ? contextSegments.join("\n\n") : null; + } + const projectInstructionRoots = await readInstructionHierarchy( runtime, - isLocalProjectRuntime(metadata.runtimeConfig) ? null : workspacePath, + workspacePath, metadata.projectPath ); return projectInstructionRoots.length > 0 ? projectInstructionRoots.join("\n\n") : null;