+
+ {branchLabel}
+
- {/* Branch actions kebab — same pattern as the session/worktree rows.
- ALWAYS laid out; only its opacity flips on hover/focus/open, so
- revealing it never reflows the row (no layout shift). pointer-events
- follow opacity so the invisible trigger isn't clickable at rest. */}
- {onBranchOff && (
-
-
+ {/* Branch actions kebab — same pattern as the session/worktree rows.
+ ALWAYS laid out; only its opacity flips on hover/focus/open, so
+ revealing it never reflows the row (no layout shift). pointer-events
+ follow opacity so the invisible trigger isn't clickable at rest. */}
+ {onBranchOff && (
+
-
- {/* The row sits at the bottom of the screen (above the composer),
- so the menu opens upward. */}
-
- {s.newBranch}
- {branchTargets.map(target => (
- startBranch(target.base)}>
- {target.label}
-
- ))}
+
+ )}
+
- {switchTarget && (
- void switchToBranch(switchTarget)}>
- {s.switchTo(switchTarget)}
-
- )}
-
-
- {s.worktrees}
- {otherWorktrees.map(worktree => (
- onOpenWorktree?.(worktree.path)}>
- {worktree.branch}
-
- ))}
- {/* Create a fresh worktree off the current HEAD (the generic
- "spin up a worktree here", mirroring the sidebar's + button). */}
- startBranch(undefined)}>
- {p.startWork}
-
- {/* Create a fresh worktree off the current HEAD (the generic
- "spin up a worktree here", mirroring the sidebar's + button). */}
- {onConvertBranch && (
- startBranch(undefined)}>
- {p.convertBranch}
-
- )}
-
-
+ {(status.ahead > 0 || status.behind > 0) && (
+
+ {status.ahead > 0 && (
+
+ ↑
+ {status.ahead}
+
+ )}
+ {status.behind > 0 && (
+
+ ↓
+ {status.behind}
+
+ )}
+
)}
-
- {(status.ahead > 0 || status.behind > 0) && (
-
- {status.ahead > 0 && (
-
- ↑
- {status.ahead}
-
- )}
- {status.behind > 0 && (
-
- ↓
- {status.behind}
-
- )}
-
- )}
-
- {hasLineDelta ? (
-
- ) : untrackedOnly ? (
-
- {s.changed(status.untracked)}
-
- ) : null}
-
+ {hasLineDelta ? (
+
+ ) : untrackedOnly ? (
+
+ {s.changed(status.untracked)}
+
+ ) : null}
+
+
{resolvedRepoPath && onOpenWorktree && (
({
// ProjectMenu (the kebab) has its own dedicated test file — stub it here so
// this file only exercises overview-row's own Tip usage (the disclosure
-// toggle) plus the WorkspaceAddButton wiring.
+// toggle) plus the WorkspaceAddButton wiring. ProjectContextMenu (the row's
+// right-click wrapper) is stubbed as a pass-through so the row still renders.
vi.mock('./project-menu', () => ({
+ ProjectContextMenu: ({ children }: { children: ReactNode }) => children,
ProjectMenu: () => null
}))
diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
index a37541ad337e..0653b01ae400 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
@@ -22,7 +22,7 @@ import {
} from '../chrome'
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
-import { ProjectMenu } from './project-menu'
+import { ProjectContextMenu, ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
@@ -112,50 +112,61 @@ export function ProjectOverviewRow({
{projectIcon(project)}
)
+ const shell = (
+
+ {/* Home has no folder to start a chat in — the sidebar's own "New
+ session" is that button — and no record to rename or delete. */}
+ {onNewSession && !project.isNoProject && (
+ onNewSession(project.path)} />
+ )}
+ {!project.isNoProject && }
+ >
+ }
+ className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
+ ref={rowRef}
+ >
+
+ {lead}
+ onEnter?.(project.id)}
+ >
+ {project.label}
+
+ {preview.length > 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+
return (
-
- {/* Home has no folder to start a chat in — the sidebar's own "New
- session" is that button — and no record to rename or delete. */}
- {onNewSession && !project.isNoProject && (
- onNewSession(project.path)} />
- )}
- {!project.isNoProject && }
- >
- }
- className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
- ref={rowRef}
- >
-
- {lead}
- onEnter?.(project.id)}
- >
- {project.label}
-
- {preview.length > 0 ? (
-
-
-
- ) : (
-
- )}
-
-
+ {/* Home has no per-project actions, so it gets no right-click menu. */}
+ {project.isNoProject ? (
+ shell
+ ) : (
+
+ {shell}
+
+ )}
{open && preview.length > 0 && {renderRows?.(preview)}}
)
diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx
new file mode 100644
index 000000000000..f6ef82745cd6
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx
@@ -0,0 +1,83 @@
+import { Codicon } from '@/components/ui/codicon'
+import { ColorSwatches } from '@/components/ui/color-swatches'
+import { Tip } from '@/components/ui/tooltip'
+import { PROFILE_SWATCHES } from '@/lib/profile-color'
+import { cn } from '@/lib/utils'
+
+// Curated codicons for a project glyph (tinted by the chosen color). Shared by
+// the kebab's Appearance popover and the right-click menu's Appearance submenu
+// so both offer the same picker.
+export const PROJECT_ICONS = [
+ 'folder-library',
+ 'repo',
+ 'rocket',
+ 'beaker',
+ 'flame',
+ 'star-full',
+ 'heart',
+ 'zap',
+ 'target',
+ 'lightbulb',
+ 'tools',
+ 'device-desktop',
+ 'device-mobile',
+ 'terminal',
+ 'dashboard',
+ 'globe',
+ 'broadcast',
+ 'cloud',
+ 'database',
+ 'package',
+ 'book',
+ 'organization',
+ 'bug',
+ 'shield',
+ 'key',
+ 'gift',
+ 'telescope',
+ 'home'
+]
+
+interface ProjectAppearancePickerProps {
+ color: null | string
+ icon: null | string
+ noColorLabel: string
+ onColor: (color: null | string) => void
+ onIcon: (icon: null | string) => void
+}
+
+/** Color swatches + icon grid for a project's appearance — one component so the
+ * kebab popover and the right-click submenu render an identical picker. */
+export function ProjectAppearancePicker({ color, icon, noColorLabel, onColor, onIcon }: ProjectAppearancePickerProps) {
+ return (
+ <>
+
+ {/* Same 6 columns + gap as the swatch grid so the picker keeps the
+ profile picker's width (icons flex to fill, not fixed-width). */}
+
+ {PROJECT_ICONS.map(name => (
+
+
+
+ ))}
+
+ >
+ )
+}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
index 6eaeaab12a4e..f601007c7e38 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
@@ -2,8 +2,14 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
+import {
+ type ActionItemSpec,
+ ActionsContextMenu,
+ DROPDOWN_KIT,
+ type MenuKit,
+ renderActionItem
+} from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
-import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
@@ -15,7 +21,6 @@ import {
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
-import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
@@ -28,45 +33,114 @@ import {
setProjectAppearance
} from '@/store/projects'
+import { ProjectAppearancePicker } from './project-appearance'
import type { SidebarProjectTree } from './workspace-groups'
-// Curated codicons for the project glyph (tinted by the chosen color).
-const ICONS = [
- 'folder-library',
- 'repo',
- 'rocket',
- 'beaker',
- 'flame',
- 'star-full',
- 'heart',
- 'zap',
- 'target',
- 'lightbulb',
- 'tools',
- 'device-desktop',
- 'device-mobile',
- 'terminal',
- 'dashboard',
- 'globe',
- 'broadcast',
- 'cloud',
- 'database',
- 'package',
- 'book',
- 'organization',
- 'bug',
- 'shield',
- 'key',
- 'gift',
- 'telescope',
- 'home'
-]
+// Shared per-project state + handlers, so the kebab dropdown and the row's
+// right-click menu drive the exact same actions. Modeled on git GUIs (GitHub
+// Desktop / GitKraken): reveal in the file manager, copy path, and "Remove from
+// sidebar" (never deletes files — auto projects are dismissed, explicit ones
+// drop their entry). Explicit projects additionally get rename / add folder /
+// set active.
+function useProjectActions({
+ project,
+ isActive,
+ scoped,
+ onExitScope
+}: {
+ project: SidebarProjectTree
+ isActive: boolean
+ scoped: boolean
+ onExitScope?: () => void
+}) {
+ const { t } = useI18n()
+ const p = t.sidebar.projects
+ const target = { id: project.id, name: project.label }
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
-// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
-// in the file manager, copy path, and "Remove from sidebar" (never deletes files
-// — auto projects are dismissed, explicit ones drop their entry). Explicit
-// projects additionally get rename / add folder / set active. Hidden until the
-// row is hovered (group/workspace), matching the + affordance.
+ const removeAuto = () => {
+ dismissAutoProject(project.id)
+
+ if (scoped) {
+ onExitScope?.()
+ }
+ }
+
+ const confirmDelete = async () => {
+ await deleteProject(project.id)
+
+ if (scoped) {
+ onExitScope?.()
+ }
+ }
+
+ // Rename / add folder / set active — explicit projects only (auto ones lack a
+ // materialized record). Appearance is handled per-surface (popover vs submenu)
+ // by the caller since its picker chrome differs.
+ const identityItems: ActionItemSpec[] = project.isAuto
+ ? []
+ : [
+ { icon: 'edit', key: 'rename', label: p.menuRename, onSelect: () => openProjectRename(target) },
+ {
+ icon: 'new-folder',
+ key: 'add-folder',
+ label: p.menuAddFolder,
+ onSelect: () => openProjectAddFolder(target)
+ },
+ {
+ disabled: isActive,
+ icon: 'target',
+ key: 'set-active',
+ label: p.menuSetActive,
+ onSelect: () => void setActiveProject(project.id)
+ }
+ ]
+
+ const pathItems: ActionItemSpec[] = [
+ {
+ disabled: !project.path,
+ icon: 'folder-opened',
+ key: 'reveal',
+ label: p.reveal,
+ onSelect: () => void revealPath(project.path)
+ },
+ {
+ disabled: !project.path,
+ icon: 'copy',
+ key: 'copy',
+ label: p.copyPath,
+ onSelect: () => void copyPath(project.path)
+ }
+ ]
+
+ const dangerItem: ActionItemSpec = project.isAuto
+ ? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' }
+ : {
+ icon: 'trash',
+ key: 'delete',
+ label: `${p.menuDelete}…`,
+ onSelect: () => setConfirmDeleteOpen(true),
+ variant: 'destructive'
+ }
+
+ const confirmDialog = (
+ setConfirmDeleteOpen(false)}
+ onConfirm={confirmDelete}
+ open={confirmDeleteOpen}
+ title={`${p.menuDelete} "${project.label}"?`}
+ />
+ )
+
+ return { confirmDialog, dangerItem, identityItems, pathItems }
+}
+
+// Per-project actions. The kebab keeps its row-anchored Appearance popover; the
+// right-click menu (ProjectContextMenu) renders the same actions with Appearance
+// as a submenu. Hidden until the row is hovered, matching the + affordance.
export function ProjectMenu({
project,
isActive,
@@ -88,28 +162,17 @@ export function ProjectMenu({
}) {
const { t } = useI18n()
const p = t.sidebar.projects
- const target = { id: project.id, name: project.label }
- const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
- const removeAuto = () => {
- dismissAutoProject(project.id)
-
- if (scoped) {
- onExitScope?.()
- }
- }
-
- const confirmDelete = async () => {
- await deleteProject(project.id)
-
- if (scoped) {
- onExitScope?.()
- }
- }
+ const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
+ isActive,
+ onExitScope,
+ project,
+ scoped
+ })
// Appearance writes route through the adopt-aware helper: an auto project is
// materialized on its first change (its id then changes), so close the picker
@@ -193,42 +256,15 @@ export function ProjectMenu({
)
) : (
<>
- openProjectRename(target)}>
-
- {p.menuRename}
-
+ {identityItems.slice(0, 1).map(item => renderActionItem(DROPDOWN_KIT, item))}
{appearanceItem}
- openProjectAddFolder(target)}>
-
- {p.menuAddFolder}
-
- void setActiveProject(project.id)}>
-
- {p.menuSetActive}
-
+ {identityItems.slice(1).map(item => renderActionItem(DROPDOWN_KIT, item))}
>
)}
- void revealPath(project.path)}>
-
- {p.reveal}
-
- void copyPath(project.path)}>
-
- {p.copyPath}
-
+ {pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
- {project.isAuto ? (
-
-
- {p.removeFromSidebar}
-
- ) : (
- setConfirmDeleteOpen(true)} variant="destructive">
-
- {`${p.menuDelete}…`}
-
- )}
+ {renderActionItem(DROPDOWN_KIT, dangerItem)}
- void applyAppearance({ color })}
- swatches={PROFILE_SWATCHES}
- value={project.color ?? null}
+ void applyAppearance({ color })}
+ onIcon={icon => void applyAppearance({ icon })}
/>
- {/* Same 6 columns + gap as the swatch grid so the popover keeps the
- profile picker's width (icons flex to fill, not fixed-width). */}
-
- {ICONS.map(name => (
-
-
-
- ))}
-
- setConfirmDeleteOpen(false)}
- onConfirm={confirmDelete}
- open={confirmDeleteOpen}
- title={`${p.menuDelete} "${project.label}"?`}
- />
+ {confirmDialog}
)
}
+
+interface ProjectContextMenuProps {
+ project: SidebarProjectTree
+ isActive: boolean
+ scoped?: boolean
+ onExitScope?: () => void
+ children: React.ReactNode
+}
+
+// Wrap a project row so right-clicking it opens the same actions as its kebab.
+// The kebab's row-anchored Appearance popover can't nest in a context menu, so
+// here Appearance is a submenu with the same swatch + icon picker.
+export function ProjectContextMenu({
+ project,
+ isActive,
+ scoped = false,
+ onExitScope,
+ children
+}: ProjectContextMenuProps) {
+ const { t } = useI18n()
+ const p = t.sidebar.projects
+
+ const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
+ isActive,
+ onExitScope,
+ project,
+ scoped
+ })
+
+ const canTheme = !project.isAuto || Boolean(project.path)
+
+ const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => {
+ void setProjectAppearance(project, patch)
+ }
+
+ const items = (kit: MenuKit) => (
+ <>
+ {identityItems.map(item => renderActionItem(kit, item))}
+ {canTheme && (
+
+
+
+ {p.menuAppearance}
+
+
+ applyAppearance({ color })}
+ onIcon={icon => applyAppearance({ icon })}
+ />
+
+
+ )}
+ {(identityItems.length > 0 || canTheme) && }
+ {pathItems.map(item => renderActionItem(kit, item))}
+
+ {renderActionItem(kit, dangerItem)}
+ >
+ )
+
+ return (
+ <>
+
+ {children}
+
+ {confirmDialog}
+ >
+ )
+}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
index 21a89f03b618..dd38e84a2b9c 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
@@ -14,7 +14,13 @@ import { SidebarLoadMoreRow } from '../load-more-row'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
-import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
+import {
+ WorkspaceAddButton,
+ WorkspaceContextMenu,
+ WorkspaceHeader,
+ WorkspaceMenu,
+ WorkspaceShowMoreButton
+} from './workspace-header'
interface SidebarWorkspaceGroupProps {
group: SidebarSessionGroup
@@ -104,29 +110,31 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
return (
-
- {(onNewSession || isProfileGroup) && (
- void handleNewSession()}
- />
- )}
- {onRemove && }
-
- )
- }
- icon={leadingIcon}
- label={group.label}
- onToggle={toggleOpen}
- open={open}
- title={group.path ?? undefined}
- />
+
+
+ {(onNewSession || isProfileGroup) && (
+ void handleNewSession()}
+ />
+ )}
+ {onRemove && }
+
+ )
+ }
+ icon={leadingIcon}
+ label={group.label}
+ onToggle={toggleOpen}
+ open={open}
+ title={group.path ?? undefined}
+ />
+
{open && (
<>
{visibleSessions.length === 0 ? (
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
index 1cfb12ea5eed..b2665abd4dc8 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
@@ -1,15 +1,9 @@
import type * as React from 'react'
import { useState } from 'react'
+import { ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -83,40 +77,77 @@ export function WorkspaceShowMoreButton({
// Per-worktree actions (linked worktree lanes only), mirroring the session row
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
-export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
+// Shared by the kebab dropdown and the header's right-click menu so both match.
+function useWorkspaceItems({ path, onRemove }: { path: null | string; onRemove: () => void }) {
const { t } = useI18n()
const p = t.sidebar.projects
+ return (kit: MenuKit) => (
+ <>
+ {renderActionItem(kit, {
+ disabled: !path,
+ icon: 'folder-opened',
+ key: 'reveal',
+ label: p.reveal,
+ onSelect: () => void revealPath(path)
+ })}
+ {renderActionItem(kit, {
+ disabled: !path,
+ icon: 'copy',
+ key: 'copy',
+ label: p.copyPath,
+ onSelect: () => void copyPath(path)
+ })}
+
+ {renderActionItem(kit, {
+ icon: 'trash',
+ key: 'remove',
+ label: `${p.removeWorktree}…`,
+ onSelect: onRemove,
+ variant: 'destructive'
+ })}
+ >
+ )
+}
+
+export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
+ const { t } = useI18n()
+ const p = t.sidebar.projects
+ const items = useWorkspaceItems({ onRemove, path })
+
return (
-
-
-
-
-
-
-
- void revealPath(path)}>
-
- {p.reveal}
-
- void copyPath(path)}>
-
- {p.copyPath}
-
-
-
-
- {`${p.removeWorktree}…`}
-
-
-
+
+
+
+ )
+}
+
+// Wrap a worktree lane's header so right-clicking it opens the same actions as
+// its kebab. `disabled` renders children bare (a lane with no removable path).
+export function WorkspaceContextMenu({
+ path,
+ onRemove,
+ children
+}: {
+ path: null | string
+ onRemove?: () => void
+ children: React.ReactNode
+}) {
+ const { t } = useI18n()
+ const p = t.sidebar.projects
+ const items = useWorkspaceItems({ onRemove: onRemove ?? (() => {}), path })
+
+ return (
+
+ {children}
+
)
}
@@ -156,7 +187,9 @@ export function WorkspaceHeader({
label,
onToggle,
open,
- title
+ title,
+ ref,
+ ...rest
}: {
action?: React.ReactNode
emphasis?: boolean
@@ -166,13 +199,15 @@ export function WorkspaceHeader({
open: boolean
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
title?: string
-}) {
+} & React.ComponentProps<'div'>) {
return (