Revert Projects sidebar +Add button (#4459)

* Revert "Make add project create action a button (#4439)"

This reverts commit 709adbc761.

* Revert "Make adding projects discoverable in sidebar (#4426)"

This reverts commit 58d36e4c1c.

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 <bot@stably.ai>
This commit is contained in:
Neil
2026-06-02 00:39:19 -07:00
committed by GitHub
co-authored by orca-bot
parent 5484792271
commit d0c8f26b2c
3 changed files with 22 additions and 391 deletions
@@ -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<string | null>(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<string | null>(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() {
<span>Want to import many repos at once? Select the parent folder.</span>
</div>
{/* Secondary link rather than a fourth card — create-from-scratch
is a less common path than importing. See orca#763. */}
<div className="flex items-center justify-center pt-1">
<Button
<button
type="button"
onClick={() => {
setCreateError(null)
setStep('create')
}}
disabled={isAdding}
variant="outline"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
>
<FolderPlus className="size-3.5" />
Or start a new project from scratch
</Button>
</button>
</div>
</>
) : step === 'remote' ? (
@@ -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<typeof mocks.state>) => {
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 (
<button
data-contextual-tour-target={props['data-contextual-tour-target']}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
}))
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: () => <hr />,
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 (
<button disabled={disabled} onClick={onSelect}>
{children}
</button>
)
}
}))
vi.mock('./SidebarWorkspaceOptionsMenu', () => ({
default: () => <div data-testid="workspace-options-menu" />
}))
vi.mock('./WorkspaceKanbanDrawer', () => ({
default: ({ open }: { open: boolean }) => (open ? <div data-testid="workspace-board" /> : 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<string> {
const { default: SidebarHeader } = await import('./SidebarHeader')
return renderToStaticMarkup(<SidebarHeader />)
}
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)
})
})
@@ -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<string, unknown> = {}) => {
openModal('add-repo', data)
},
[openModal]
)
const openWorkspaceBoard = useCallback(() => {
if (workspaceBoardOpenRef.current) {
return
@@ -112,7 +94,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<>
<div className="mt-2 flex h-8 items-center justify-between px-2 gap-2">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
<span className="pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
{sidebarTitle}
</span>
</div>
@@ -141,76 +123,29 @@ const SidebarHeader = React.memo(function SidebarHeader() {
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} onOpenChange={setWorkspaceBoardMenuOpen}>
<DropdownMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
// Why: Direction A combines workspace creation and adding a
// project under the Projects header, so the affordance reads
// as "Add" near the list rather than another bare plus icon.
size={isRepoGrouping ? 'xs' : 'icon-xs'}
aria-label="Add to Orca"
className={isRepoGrouping ? 'gap-1 text-muted-foreground' : 'text-muted-foreground'}
data-contextual-tour-target={
canCreateWorkspace ? 'workspace-create-control' : undefined
}
>
<Plus className="size-3.5" strokeWidth={2.25} />
{isRepoGrouping ? <span className="text-[11px]">Add</span> : null}
{isRepoGrouping ? <ChevronDown className="size-3 opacity-60" /> : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6} className="w-64">
<DropdownMenuLabel>Worktree</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() => {
size="icon-xs"
onClick={() => {
if (!canCreateWorkspace) {
return
}
openWorkspaceCreationComposerWithTourHandoff()
openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
}}
aria-label="New workspace"
disabled={!canCreateWorkspace}
>
<Plus className="size-3.5" />
<span className="flex min-w-0 flex-col">
<span>New worktree</span>
</span>
{canCreateWorkspace ? (
<DropdownMenuShortcut>{newWorktreeShortcutLabel}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Project</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => openAddRepo({ autoBrowse: true })}>
<FolderPlus className="size-3.5" />
<span className="flex min-w-0 flex-col">
<span>Open local folder...</span>
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
Pick a Git repo on this machine
</span>
</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openAddRepo({ initialStep: 'clone' })}>
<GitBranch className="size-3.5" />
<span className="flex min-w-0 flex-col">
<span>Clone from GitHub / GitLab...</span>
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
Paste a repository URL
</span>
</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openAddRepo({ initialStep: 'remote' })}>
<Monitor className="size-3.5" />
<span className="flex min-w-0 flex-col">
<span>Remote (SSH) project...</span>
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
Connect to a repo on a remote host
</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Plus className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorkspace
? `New workspace (${newWorktreeShortcutLabel})`
: 'Add a project to create workspaces'}
</TooltipContent>
</Tooltip>
</div>
</div>
<WorkspaceKanbanDrawer