From 0d14864b94c1b04aa5e0ff6f0ec7273a2d3940c5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:04:39 -0500 Subject: [PATCH 1/5] refactor(desktop): extract a shared kebab + right-click actions-menu primitive Promote the session row's inline MenuKit device into components/ui/ actions-menu.tsx: one ActionsMenu (kebab) + ActionsContextMenu (right-click) pair driven by a single items(kit) render function, so a row's dropdown and its context menu can't drift. Refactor the session menu onto it and let StatusRow forward ref/onContextMenu so any row can host a context menu. --- .../app/chat/sidebar/session-actions-menu.tsx | 136 ++++---------- .../src/components/chat/status-row.tsx | 10 +- .../src/components/ui/actions-menu.tsx | 177 ++++++++++++++++++ 3 files changed, 219 insertions(+), 104 deletions(-) create mode 100644 apps/desktop/src/components/ui/actions-menu.tsx diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 7b0d1a1e649c..47095263e723 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -8,19 +8,10 @@ import { closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store' +import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger -} from '@/components/ui/context-menu' import { CopyButton } from '@/components/ui/copy-button' import { Dialog, @@ -30,18 +21,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' -import { Tip } from '@/components/ui/tooltip' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -131,42 +111,6 @@ interface SessionActions { onHideTabBar?: () => void } -type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem - -/** A menu flavour (dropdown / context) — item + separator + submenu components. */ -interface MenuKit { - Item: MenuItem - Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator - Sub: typeof DropdownMenuSub | typeof ContextMenuSub - SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger - SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent -} - -const DROPDOWN_KIT: MenuKit = { - Item: DropdownMenuItem, - Separator: DropdownMenuSeparator, - Sub: DropdownMenuSub, - SubContent: DropdownMenuSubContent, - SubTrigger: DropdownMenuSubTrigger -} - -const CONTEXT_KIT: MenuKit = { - Item: ContextMenuItem, - Separator: ContextMenuSeparator, - Sub: ContextMenuSub, - SubContent: ContextMenuSubContent, - SubTrigger: ContextMenuSubTrigger -} - -interface ItemSpec { - className?: string - disabled: boolean - icon: string - label: string - onSelect: (event: Event) => void - variant?: 'destructive' -} - // The color picker inside the session menu's Appearance submenu. Its own // component so only an OPEN submenu subscribes to the stores (not every row's // menu). Reads/writes the override keyed by the DURABLE id so a color survives @@ -212,11 +156,11 @@ function useSessionActions({ // a tab): offering "Open in new tab" again is noise. const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId) - const spec = (partial: Omit & { onSelect: () => void }): ItemSpec => partial + const spec = (partial: Omit & { onSelect: () => void }): ActionItemSpec => partial // OPEN — where else this session can go. A tab surface IS a tab already, // so it only offers the window hop (and its own Close, below). - const openItems: ItemSpec[] = [ + const openItems: ActionItemSpec[] = [ ...(surface === 'row' && !alreadyTabbed ? [ spec({ @@ -248,7 +192,7 @@ function useSessionActions({ ] // IDENTITY — name/mark/reference the session. - const identityItems: ItemSpec[] = [ + const identityItems: ActionItemSpec[] = [ spec({ disabled: !sessionId, icon: 'edit', @@ -270,7 +214,7 @@ function useSessionActions({ ] // WORK — derive/extract from the session. - const workItems: ItemSpec[] = [ + const workItems: ActionItemSpec[] = [ spec({ disabled: !onBranch, // Fork glyph to match the inline message action's GitFork icon @@ -297,7 +241,7 @@ function useSessionActions({ // TAB — close verbs that act on the strip (tabs only; a row isn't a tab). const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null - const tabCloseItems: ItemSpec[] = + const tabCloseItems: ActionItemSpec[] = surface === 'tab' ? [ ...(onClose @@ -348,7 +292,7 @@ function useSessionActions({ : [] // DANGER — put it away / destroy it (delete stays last, destructive-red). - const dangerItems: ItemSpec[] = [ + const dangerItems: ActionItemSpec[] = [ spec({ disabled: !onArchive, icon: 'archive', @@ -371,18 +315,11 @@ function useSessionActions({ } ] - const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => ( - - - {label} - - ) - const renderItems = (kit: MenuKit) => ( <> - {openItems.map(item => renderMenuItem(kit.Item, item))} + {openItems.map(item => renderActionItem(kit, item))} {openItems.length > 0 && } - {identityItems.map(item => renderMenuItem(kit.Item, item))} + {identityItems.map(item => renderActionItem(kit, item))} @@ -393,7 +330,7 @@ function useSessionActions({ - {workItems.map(item => renderMenuItem(kit.Item, item))} + {workItems.map(item => renderActionItem(kit, item))} {tabCloseItems.length > 0 && ( <> - {tabCloseItems.map(item => renderMenuItem(kit.Item, item))} + {tabCloseItems.map(item => renderActionItem(kit, item))} )} - {dangerItems.map(item => renderMenuItem(kit.Item, item))} + {dangerItems.map(item => renderActionItem(kit, item))} {onHideTabBar && ( <> - {renderMenuItem(kit.Item, { + {renderActionItem(kit, { disabled: false, icon: 'eye-closed', label: r.hideTabBar, @@ -443,13 +380,9 @@ function useSessionActions({ } interface SessionActionsMenuProps - extends SessionActions, Pick, 'align' | 'sideOffset'> { + extends SessionActions, Pick, 'align' | 'sideOffset'> { children: React.ReactNode - /** Tooltip label for the trigger. Composed INSIDE the dropdown trigger - * (Tip wraps DropdownMenuTrigger, not the other way around) — Tip doesn't - * forward the extra props/ref an `asChild` clone injects, so putting it as - * the trigger's direct child silently drops onClick/aria-haspopup/ref and - * the menu stops opening (#67500). */ + /** Tooltip label for the trigger. */ tooltip?: React.ReactNode } @@ -462,23 +395,19 @@ export function SessionActionsMenu({ }: SessionActionsMenuProps) { const { t } = useI18n() const { renameDialog, renderItems } = useSessionActions(actions) - const [open, setOpen] = useState(false) return ( <> - - - {children} - - - {renderItems(DROPDOWN_KIT)} - - + + {children} + {renameDialog} ) @@ -494,12 +423,13 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP return ( <> - - {children} - - {renderItems(CONTEXT_KIT)} - - + + {children} + {renameDialog} ) diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index 575fb5617423..316b8b0619cf 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -1,4 +1,4 @@ -import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { type KeyboardEvent, type MouseEvent, type ReactNode, type Ref } from 'react' import { cn } from '@/lib/utils' @@ -15,6 +15,10 @@ interface StatusRowProps { /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ trailing?: ReactNode trailingVisible?: boolean + /** Forwarded to the row's root — lets a wrapper (e.g. a context-menu trigger + * using `asChild`) attach `ref` / `onContextMenu` to the real DOM node. */ + ref?: Ref + onContextMenu?: (event: MouseEvent) => void } /** @@ -29,6 +33,8 @@ export function StatusRow({ className, leading, onActivate, + onContextMenu, + ref, trailing, trailingVisible = false }: StatusRowProps) { @@ -41,6 +47,7 @@ export function StatusRow({ className )} onClick={onActivate} + onContextMenu={onContextMenu} onKeyDown={ onActivate ? event => { @@ -51,6 +58,7 @@ export function StatusRow({ } : undefined } + ref={ref} role={onActivate ? 'button' : undefined} tabIndex={onActivate ? 0 : undefined} > diff --git a/apps/desktop/src/components/ui/actions-menu.tsx b/apps/desktop/src/components/ui/actions-menu.tsx new file mode 100644 index 000000000000..2aa7f7d07734 --- /dev/null +++ b/apps/desktop/src/components/ui/actions-menu.tsx @@ -0,0 +1,177 @@ +import type * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tip } from '@/components/ui/tooltip' + +// One place to define a set of actions and get BOTH a kebab dropdown and a +// matching right-click context menu — so a row's ⋯ menu and its right-click menu +// never drift. The dropdown and context primitives share an identical item +// surface (Item / Separator / Sub…), so a caller writes `items={kit => …}` once +// and hands the render function to both wrappers. +// +// The pattern originated inline in the session row menu; it lives here so every +// kebab in the app can add right-click parity in one line. + +/** A menu flavour (dropdown / context) — the item + separator + submenu parts. */ +export interface MenuKit { + Item: typeof DropdownMenuItem | typeof ContextMenuItem + Label: typeof DropdownMenuLabel | typeof ContextMenuLabel + Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator + Sub: typeof DropdownMenuSub | typeof ContextMenuSub + SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger + SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent + /** `CopyButton`'s `appearance` for this flavour — pass to a menu-item copy. */ + copyAppearance: 'context-menu-item' | 'menu-item' +} + +export const DROPDOWN_KIT: MenuKit = { + Item: DropdownMenuItem, + Label: DropdownMenuLabel, + Separator: DropdownMenuSeparator, + Sub: DropdownMenuSub, + SubContent: DropdownMenuSubContent, + SubTrigger: DropdownMenuSubTrigger, + copyAppearance: 'menu-item' +} + +export const CONTEXT_KIT: MenuKit = { + Item: ContextMenuItem, + Label: ContextMenuLabel, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubContent: ContextMenuSubContent, + SubTrigger: ContextMenuSubTrigger, + copyAppearance: 'context-menu-item' +} + +/** A single action row. Provide `icon` (codicon name) or `iconNode` (any node). */ +export interface ActionItemSpec { + className?: string + disabled?: boolean + icon?: string + iconNode?: React.ReactNode + /** Stable key; defaults to `label` when it's a string. */ + key?: string + label: React.ReactNode + onSelect: (event: Event) => void + variant?: 'default' | 'destructive' +} + +/** Render one `ActionItemSpec` with the given kit's Item component. */ +export function renderActionItem(kit: MenuKit, { className, disabled, icon, iconNode, key, label, onSelect, variant }: ActionItemSpec) { + return ( + + {iconNode ?? (icon ? : null)} + {typeof label === 'string' ? {label} : label} + + ) +} + +interface ActionsMenuProps + extends Pick, 'align' | 'side' | 'sideOffset'> { + /** The trigger (a kebab button). Wrapped in `DropdownMenuTrigger asChild`. */ + children: React.ReactNode + /** The action rows, rendered with `DROPDOWN_KIT`. Share this with `ActionsContextMenu`. */ + items: (kit: MenuKit) => React.ReactNode + ariaLabel?: string + contentClassName?: string + /** Optional tooltip on the trigger (composed INSIDE the asChild chain). */ + tooltip?: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +/** + * A kebab dropdown menu. Pair it with `ActionsContextMenu` using the same + * `items` render function so the two menus stay identical. + */ +export function ActionsMenu({ + align = 'end', + ariaLabel, + children, + contentClassName, + items, + onOpenChange, + open, + side, + sideOffset = 6, + tooltip +}: ActionsMenuProps) { + // Tip wraps the trigger, not the reverse: Tip doesn't forward the ref/props an + // `asChild` clone injects, so a Tip placed as the trigger's child silently + // drops onClick/aria-haspopup and the menu stops opening (#67500). + const trigger = {children} + + return ( + + {tooltip ? {trigger} : trigger} + + {items(DROPDOWN_KIT)} + + + ) +} + +interface ActionsContextMenuProps { + /** The area that receives right-click. Wrapped in `ContextMenuTrigger asChild`. */ + children: React.ReactNode + /** The action rows, rendered with `CONTEXT_KIT`. Share this with `ActionsMenu`. */ + items: (kit: MenuKit) => React.ReactNode + ariaLabel?: string + contentClassName?: string + /** Skip the wrapper (render children bare) — e.g. nothing is actionable yet. */ + disabled?: boolean +} + +/** + * Wrap a row so right-clicking it opens the same menu as its kebab. Pass the + * kebab's `items` render function so both surfaces mirror each other. + */ +export function ActionsContextMenu({ ariaLabel, children, contentClassName, disabled, items }: ActionsContextMenuProps) { + if (disabled) { + return <>{children} + } + + return ( + + {children} + + {items(CONTEXT_KIT)} + + + ) +} From 84a25212de8c14ce7ab0c1838cf61444b55f4212 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:04:45 -0500 Subject: [PATCH 2/5] feat(desktop): mirror every kebab menu to right-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the shared actions-menu into the remaining kebabs so right-clicking a row opens the same actions as its ⋯ button: project rows (appearance moves to a submenu via the new shared ProjectAppearancePicker), worktree lanes, the composer branch bar, and settings credential rows. --- .../chat/composer/status-stack/coding-row.tsx | 214 +++++++------ .../sidebar/projects/overview-row.test.tsx | 5 +- .../chat/sidebar/projects/overview-row.tsx | 97 +++--- .../sidebar/projects/project-appearance.tsx | 83 +++++ .../chat/sidebar/projects/project-menu.tsx | 303 +++++++++++------- .../chat/sidebar/projects/workspace-group.tsx | 56 ++-- .../sidebar/projects/workspace-header.tsx | 113 ++++--- .../src/app/settings/env-var-actions-menu.tsx | 186 ++++++----- .../src/app/settings/toolset-config-panel.tsx | 110 ++++--- 9 files changed, 706 insertions(+), 461 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 5093658af88c..4ffc400a6525 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -3,17 +3,10 @@ import { memo, useEffect, useRef, useState } from 'react' import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog' import { StatusRow } from '@/components/chat/status-row' +import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DiffCount } from '@/components/ui/diff-count' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { $repoStatus, $repoWorktrees } from '@/store/coding-status' @@ -153,34 +146,87 @@ export const CodingStatusRow = memo(function CodingStatusRow({ // they're the only change (otherwise +/- tells the story). const untrackedOnly = !hasLineDelta && status.untracked > 0 + // The branch actions, rendered identically by the kebab dropdown and the + // row's right-click menu so the two never drift. `onBranchOff` gates the + // whole menu (omitted = remote backend), matching the kebab. + const renderBranchItems = (kit: MenuKit) => { + const branchItems: ActionItemSpec[] = branchTargets.map(target => ({ + key: target.base ?? '__head__', + label: {target.label}, + onSelect: () => startBranch(target.base) + })) + + const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({ + key: worktree.path, + label: {worktree.branch}, + onSelect: () => onOpenWorktree?.(worktree.path) + })) + + return ( + <> + {s.newBranch} + {branchItems.map(item => renderActionItem(kit, item))} + {switchTarget && + renderActionItem(kit, { + key: '__switch__', + label: {s.switchTo(switchTarget)}, + onSelect: () => void switchToBranch(switchTarget) + })} + + {s.worktrees} + {worktreeItems.map(item => renderActionItem(kit, item))} + {/* Create a fresh worktree off the current HEAD (the generic "spin up a + worktree here", mirroring the sidebar's + button). */} + {renderActionItem(kit, { + key: '__start__', + label: {p.startWork}, + onSelect: () => startBranch(undefined) + })} + {onConvertBranch && + renderActionItem(kit, { + key: '__convert__', + label: {p.convertBranch}, + onSelect: () => startBranch(undefined) + })} + + ) + } + return ( <> - } - onActivate={onOpen} - > -
- - {branchLabel} - + + } + onActivate={onOpen} + > +
+ + {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..801702bc5c76 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,8 @@ 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 +15,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 +27,108 @@ 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 +150,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 +244,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 (
+ +
)} - - {isSet && revealed !== null && ( -
- {revealed || '---'} -
- )} - - {editing && ( -
- setValue(e.target.value)} - placeholder={envVar.prompt || envVar.key} - type={envVar.default ? 'text' : 'password'} - value={value} - /> - - -
- )} - + ) } From c8806728467b91388d293ea602da12631130b9ae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:17:36 -0500 Subject: [PATCH 3/5] fix(desktop): send a message edit when the arrow is clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the edit composer's send arrow did nothing — the edit only went through via revert. On macOS a