From d0c8f26b2c22d593c22239be2afd80161873ec5d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:39:19 -0700 Subject: [PATCH] Revert Projects sidebar +Add button (#4459) * Revert "Make add project create action a button (#4439)" This reverts commit 709adbc7610123cd28316fb8075ba8de36182fe3. * Revert "Make adding projects discoverable in sidebar (#4426)" This reverts commit 58d36e4c1c09ba12d1decfce531128d2918238c8. Conflict resolution notes: - Deleted SidebarHeader.test.tsx (introduced by #4426; subsequent edits from #2734 are dropped along with the test file). - Restored SidebarHeader.tsx to its pre-#4426 state. The contextual-tour anchor data-contextual-tour-target="workspace-create-control" added by #2734 no longer has a host element; the workspace-creation tour will need to be re-anchored separately. --------- Co-authored-by: orca-bot --- .../src/components/sidebar/AddRepoDialog.tsx | 51 +--- .../components/sidebar/SidebarHeader.test.tsx | 263 ------------------ .../src/components/sidebar/SidebarHeader.tsx | 99 ++----- 3 files changed, 22 insertions(+), 391 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/SidebarHeader.test.tsx diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 317f104ca3f..59b0eecbf19 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -6,7 +6,6 @@ import { ArrowLeft, Globe, Monitor, - FolderPlus, FolderTree, Lightbulb, Loader2, @@ -184,10 +183,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // Why: a dropped path is modal data, so ordinary state updates must not // re-run the import while the Add Project dialog advances through steps. const droppedLocalPathHandledRef = useRef(null) - // Why: header add-menu routing is modal data; handle it once per modal open - // so later state updates don't kick the user back to the requested entry step. - const initialStepHandledRef = useRef(null) - const autoBrowseHandledRef = useRef(false) // Why: track whether we've already auto-filled for this entry into the clone step, // so a late settings hydration still gets a chance to set the default. const cloneStepAutoFilledRef = useRef(false) @@ -279,11 +274,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const isOpen = activeModal === 'add-repo' const droppedLocalPath = typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' - const initialStep = - modalData.initialStep === 'clone' || modalData.initialStep === 'remote' - ? modalData.initialStep - : 'add' - const autoBrowse = modalData.autoBrowse === true const projectId = addedRepo?.id ?? '' const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) @@ -361,8 +351,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { useEffect(() => { if (!isOpen) { droppedLocalPathHandledRef.current = null - initialStepHandledRef.current = null - autoBrowseHandledRef.current = false resetState() } }, [isOpen, resetState]) @@ -502,35 +490,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } }, [handleAddLocalPath]) - useEffect(() => { - if (!isOpen) { - return - } - const routeKey = `${initialStep}:${autoBrowse ? 'browse' : 'manual'}` - if (initialStepHandledRef.current === routeKey) { - return - } - initialStepHandledRef.current = routeKey - if (initialStep === 'clone') { - setCloneError(null) - setStep('clone') - return - } - if (initialStep === 'remote') { - void handleOpenRemoteStep() - return - } - setStep('add') - }, [autoBrowse, handleOpenRemoteStep, initialStep, isOpen]) - - useEffect(() => { - if (!isOpen || !autoBrowse || autoBrowseHandledRef.current) { - return - } - autoBrowseHandledRef.current = true - void handleBrowse() - }, [autoBrowse, handleBrowse, isOpen]) - const handleImportNestedRepos = useCallback( async (mode: 'group' | 'separate') => { const attemptId = nestedAttemptId @@ -1245,20 +1204,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { Want to import many repos at once? Select the parent folder. + {/* Secondary link rather than a fourth card — create-from-scratch + is a less common path than importing. See orca#763. */}
- +
) : step === 'remote' ? ( diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx deleted file mode 100644 index 0731f8c0255..00000000000 --- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' - -type ButtonCapture = { - label: string - ariaLabel?: string - dataContextualTourTarget?: string - size?: string - onClick?: () => unknown - disabled?: boolean -} - -type MenuItemCapture = { - label: string - dataContextualTourTarget?: string - onSelect?: () => unknown - disabled?: boolean -} - -const mocks = vi.hoisted(() => ({ - buttons: [] as ButtonCapture[], - menuItems: [] as MenuItemCapture[], - state: { - openModal: vi.fn(), - repos: [] as Repo[], - groupBy: 'repo' as 'repo' | 'status', - recordFeatureInteraction: vi.fn() - } -})) - -function textContent(node: ReactModule.ReactNode): string { - if (node == null || typeof node === 'boolean') { - return '' - } - if (typeof node === 'string' || typeof node === 'number') { - return String(node) - } - if (Array.isArray(node)) { - return node.map(textContent).join('') - } - if (typeof node === 'object' && 'props' in node) { - return textContent((node as { props?: { children?: ReactModule.ReactNode } }).props?.children) - } - return '' -} - -vi.mock('@/store', () => { - const useAppStore = Object.assign( - (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), - { - getState: () => mocks.state, - setState: (next: Partial) => { - Object.assign(mocks.state, next) - } - } - ) - return { useAppStore } -}) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - onClick, - disabled, - size, - ...props - }: { - children: ReactModule.ReactNode - onClick?: () => unknown - disabled?: boolean - size?: string - 'aria-label'?: string - 'data-contextual-tour-target'?: string - }) => { - mocks.buttons.push({ - label: textContent(children), - ariaLabel: props['aria-label'], - dataContextualTourTarget: props['data-contextual-tour-target'], - size, - onClick, - disabled - }) - return ( - - ) - } -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactModule.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - DropdownMenuTrigger: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - DropdownMenuLabel: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - DropdownMenuSeparator: () =>
, - DropdownMenuShortcut: ({ children }: { children: ReactModule.ReactNode }) => <>{children}, - DropdownMenuItem: ({ - children, - onSelect, - disabled, - ...props - }: { - children: ReactModule.ReactNode - onSelect?: () => unknown - disabled?: boolean - 'data-contextual-tour-target'?: string - }) => { - mocks.menuItems.push({ - label: textContent(children), - dataContextualTourTarget: props['data-contextual-tour-target'], - onSelect, - disabled - }) - return ( - - ) - } -})) - -vi.mock('./SidebarWorkspaceOptionsMenu', () => ({ - default: () =>
-})) - -vi.mock('./WorkspaceKanbanDrawer', () => ({ - default: ({ open }: { open: boolean }) => (open ?
: null) -})) - -vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: () => '⌘N' -})) - -function findButton(predicate: (entry: ButtonCapture) => boolean): ButtonCapture | undefined { - return mocks.buttons.find(predicate) -} - -function findMenuItem(predicate: (entry: MenuItemCapture) => boolean): MenuItemCapture | undefined { - return mocks.menuItems.find(predicate) -} - -describe('SidebarHeader', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.buttons = [] - mocks.menuItems = [] - mocks.state.repos = [] - mocks.state.groupBy = 'repo' - }) - - async function render(): Promise { - const { default: SidebarHeader } = await import('./SidebarHeader') - return renderToStaticMarkup() - } - - it('renders an enabled Add to Orca menu with zero repos and routes local folder to add-repo', async () => { - await render() - - const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca') - expect(addToOrca).toBeDefined() - expect(addToOrca?.disabled).toBeFalsy() - - const openLocalFolder = findMenuItem((b) => b.label.includes('Open local folder')) - expect(openLocalFolder).toBeDefined() - - openLocalFolder?.onSelect?.() - expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { autoBrowse: true }) - }) - - it('keeps New worktree disabled with zero repos inside the Add menu', async () => { - await render() - - const newWorkspace = findMenuItem((b) => b.label.includes('New worktree')) - expect(newWorkspace?.disabled).toBe(true) - - newWorkspace?.onSelect?.() - expect(mocks.state.openModal).not.toHaveBeenCalledWith( - 'new-workspace-composer', - expect.anything() - ) - }) - - it('anchors the workspace creation tour to the visible Add trigger when a worktree can be created', async () => { - mocks.state.repos = [ - { - id: 'repo-1', - path: '/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1 - } - ] - - await render() - - const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca') - expect(addToOrca?.dataContextualTourTarget).toBe('workspace-create-control') - - const newWorkspace = findMenuItem((b) => b.label.includes('New worktree')) - // Why: closed dropdown items are not reliable tour anchors; the visible - // trigger stays measurable while the handoff remains on this menu action. - expect(newWorkspace?.dataContextualTourTarget).toBeUndefined() - }) - - it('shows the compact visible Add label under repo grouping', async () => { - mocks.state.groupBy = 'repo' - await render() - - const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca') - expect(addToOrca?.label).toContain('Add') - expect(addToOrca?.size).toBe('xs') - }) - - it('renders an icon-only Add to Orca trigger under non-repo grouping', async () => { - mocks.state.groupBy = 'status' - await render() - - const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca') - // Icon-only: no visible text label, sized to match neighboring controls. - expect(addToOrca?.label).toBe('') - expect(addToOrca?.size).toBe('icon-xs') - - findMenuItem((b) => b.label.includes('Clone from GitHub'))?.onSelect?.() - expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { initialStep: 'clone' }) - }) - - it('still renders the workspace board and Add menu controls', async () => { - await render() - - expect(findButton((b) => b.ariaLabel === 'Workspace board')).toBeDefined() - expect(findButton((b) => b.ariaLabel === 'Add to Orca')).toBeDefined() - expect(findMenuItem((b) => b.label.includes('Remote (SSH) project'))).toBeDefined() - }) - - it('does not toggle the workspace board path when Add project is selected after opening the board', async () => { - await render() - - const workspaceBoard = findButton((b) => b.ariaLabel === 'Workspace board') - const remoteProject = findMenuItem((b) => b.label.includes('Remote (SSH) project')) - - workspaceBoard?.onClick?.() - expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledTimes(1) - expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('workspace-board') - - remoteProject?.onSelect?.() - - expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { initialStep: 'remote' }) - expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 80a2dc2473a..f6c8e8d3bc7 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,21 +1,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { ChevronDown, FolderPlus, GitBranch, Kanban, Monitor, Plus } from 'lucide-react' +import { Kanban, Plus } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tours/workspace-creation-tour-handoff' const SidebarHeader = React.memo(function SidebarHeader() { const newWorktreeShortcutLabel = useShortcutLabel('workspace.create') @@ -26,17 +16,9 @@ const SidebarHeader = React.memo(function SidebarHeader() { const repos = useAppStore((s) => s.repos) const groupBy = useAppStore((s) => s.groupBy) const canCreateWorkspace = repos.length > 0 - const isRepoGrouping = groupBy === 'repo' - const sidebarTitle = isRepoGrouping ? 'Projects' : 'Workspaces' + const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces' workspaceBoardOpenRef.current = workspaceBoardOpen - const openAddRepo = useCallback( - (data: Record = {}) => { - openModal('add-repo', data) - }, - [openModal] - ) - const openWorkspaceBoard = useCallback(() => { if (workspaceBoardOpenRef.current) { return @@ -112,7 +94,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { <>
- + {sidebarTitle}
@@ -141,76 +123,29 @@ const SidebarHeader = React.memo(function SidebarHeader() { - - + + - - - Worktree - { + size="icon-xs" + onClick={() => { if (!canCreateWorkspace) { return } - openWorkspaceCreationComposerWithTourHandoff() + openModal('new-workspace-composer', { telemetrySource: 'sidebar' }) }} aria-label="New workspace" disabled={!canCreateWorkspace} > - - - New worktree - - {canCreateWorkspace ? ( - {newWorktreeShortcutLabel} - ) : null} - - - Project - openAddRepo({ autoBrowse: true })}> - - - Open local folder... - - Pick a Git repo on this machine - - - - openAddRepo({ initialStep: 'clone' })}> - - - Clone from GitHub / GitLab... - - Paste a repository URL - - - - openAddRepo({ initialStep: 'remote' })}> - - - Remote (SSH) project... - - Connect to a repo on a remote host - - - - - + + + + + {canCreateWorkspace + ? `New workspace (${newWorktreeShortcutLabel})` + : 'Add a project to create workspaces'} + +