Merge pull request #72987 from NousResearch/bb/context-menu-parity

Mirror every desktop kebab menu to right-click
This commit is contained in:
brooklyn! 2026-07-27 19:12:04 -05:00 committed by GitHub
commit 67e592b015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 925 additions and 565 deletions

View file

@ -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: <span className="truncate">{target.label}</span>,
onSelect: () => startBranch(target.base)
}))
const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({
key: worktree.path,
label: <span className="truncate">{worktree.branch}</span>,
onSelect: () => onOpenWorktree?.(worktree.path)
}))
return (
<>
<kit.Label className={MENU_SECTION}>{s.newBranch}</kit.Label>
{branchItems.map(item => renderActionItem(kit, item))}
{switchTarget &&
renderActionItem(kit, {
key: '__switch__',
label: <span className="truncate">{s.switchTo(switchTarget)}</span>,
onSelect: () => void switchToBranch(switchTarget)
})}
<kit.Separator />
<kit.Label className={MENU_SECTION}>{s.worktrees}</kit.Label>
{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: <span className="truncate">{p.startWork}</span>,
onSelect: () => startBranch(undefined)
})}
{onConvertBranch &&
renderActionItem(kit, {
key: '__convert__',
label: <span className="truncate">{p.convertBranch}</span>,
onSelect: () => startBranch(undefined)
})}
</>
)
}
return (
<>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
<ActionsContextMenu contentClassName="w-60" disabled={!onBranchOff} items={renderBranchItems}>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
{/* 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 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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 && (
<ActionsMenu
align="end"
contentClassName="w-60"
// The row sits at the bottom of the screen (above the composer),
// so the menu opens upward.
items={renderBranchItems}
side="top"
>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
@ -197,78 +243,42 @@ export const CodingStatusRow = memo(function CodingStatusRow({
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
{/* The row sits at the bottom of the screen (above the composer),
so the menu opens upward. */}
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
{branchTargets.map(target => (
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
<span className="truncate">{target.label}</span>
</DropdownMenuItem>
))}
</ActionsMenu>
)}
</div>
{switchTarget && (
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
<span className="truncate">{s.switchTo(switchTarget)}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
{otherWorktrees.map(worktree => (
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
<span className="truncate">{worktree.branch}</span>
</DropdownMenuItem>
))}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden></span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden></span>
{status.behind}
</span>
)}
</span>
)}
</div>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden></span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden></span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
</ActionsContextMenu>
{resolvedRepoPath && onOpenWorktree && (
<WorktreeDialog

View file

@ -1,4 +1,5 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/hermes'
@ -31,8 +32,10 @@ vi.mock('./model', () => ({
// 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
}))

View file

@ -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({
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
)
const shell = (
<SidebarRowShell
actions={
<>
{/* 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 && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<Tip label={s.projects.toggle(project.label)}>
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
)
return (
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
<SidebarRowShell
actions={
<>
{/* 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 && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<Tip label={s.projects.toggle(project.label)}>
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
{/* Home has no per-project actions, so it gets no right-click menu. */}
{project.isNoProject ? (
shell
) : (
<ProjectContextMenu isActive={isActive} project={project}>
{shell}
</ProjectContextMenu>
)}
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
</div>
)

View file

@ -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 (
<>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={noColorLabel}
onChange={onColor}
swatches={PROFILE_SWATCHES}
value={color ?? null}
/>
{/* Same 6 columns + gap as the swatch grid so the picker keeps the
profile picker's width (icons flex to fill, not fixed-width). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{PROJECT_ICONS.map(name => (
<Tip key={name} label={name}>
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => onIcon(icon === name ? null : name)}
style={icon === name && color ? { color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
</Tip>
))}
</div>
</>
)
}

View file

@ -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 = (
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => 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({
)
) : (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
{identityItems.slice(0, 1).map(item => renderActionItem(DROPDOWN_KIT, item))}
{appearanceItem}
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
</DropdownMenuItem>
{identityItems.slice(1).map(item => renderActionItem(DROPDOWN_KIT, item))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
{pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
<DropdownMenuSeparator />
{project.isAuto ? (
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{p.removeFromSidebar}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.menuDelete}`}</span>
</DropdownMenuItem>
)}
{renderActionItem(DROPDOWN_KIT, dangerItem)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent
@ -238,43 +262,80 @@ export function ProjectMenu({
side={panesFlipped ? 'left' : 'right'}
sideOffset={6}
>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => void applyAppearance({ color })}
swatches={PROFILE_SWATCHES}
value={project.color ?? null}
<ProjectAppearancePicker
color={project.color ?? null}
icon={project.icon ?? null}
noColorLabel={p.noColor}
onColor={color => 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). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<Tip key={name} label={name}>
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => void applyAppearance({ icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
</Tip>
))}
</div>
</PopoverContent>
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={confirmDelete}
open={confirmDeleteOpen}
title={`${p.menuDelete} "${project.label}"?`}
/>
{confirmDialog}
</Popover>
)
}
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 && (
<kit.Sub>
<kit.SubTrigger>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</kit.SubTrigger>
<kit.SubContent className="w-auto p-2">
<ProjectAppearancePicker
color={project.color ?? null}
icon={project.icon ?? null}
noColorLabel={p.noColor}
onColor={color => applyAppearance({ color })}
onIcon={icon => applyAppearance({ icon })}
/>
</kit.SubContent>
</kit.Sub>
)}
{(identityItems.length > 0 || canTheme) && <kit.Separator />}
{pathItems.map(item => renderActionItem(kit, item))}
<kit.Separator />
{renderActionItem(kit, dangerItem)}
</>
)
return (
<>
<ActionsContextMenu ariaLabel={p.menu} contentClassName="w-48" items={items}>
{children}
</ActionsContextMenu>
{confirmDialog}
</>
)
}

View file

@ -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 (
<SidebarRowStack>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ?? undefined}
/>
<WorkspaceContextMenu onRemove={onRemove} path={group.path}>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ?? undefined}
/>
</WorkspaceContextMenu>
{open && (
<>
{visibleSessions.length === 0 ? (

View file

@ -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)
})}
<kit.Separator />
{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 (
<DropdownMenu>
<Tip label={p.menu}>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ActionsMenu ariaLabel={p.menu} contentClassName="w-48" items={items} tooltip={p.menu}>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</ActionsMenu>
)
}
// 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 (
<ActionsContextMenu ariaLabel={p.menu} contentClassName="w-48" disabled={!onRemove} items={items}>
{children}
</ActionsContextMenu>
)
}
@ -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 (
<div
className={cn(
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
)}
ref={ref}
{...rest}
>
<button
className={cn(

View file

@ -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<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
const spec = (partial: Omit<ActionItemSpec, 'onSelect'> & { 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) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
)
const renderItems = (kit: MenuKit) => (
<>
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.map(item => renderActionItem(kit, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
{identityItems.map(item => renderActionItem(kit, item))}
<kit.Sub>
<kit.SubTrigger disabled={!sessionId}>
<Codicon name="symbol-color" size="0.875rem" />
@ -393,7 +330,7 @@ function useSessionActions({
</kit.SubContent>
</kit.Sub>
<CopyButton
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={kit.copyAppearance}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@ -403,19 +340,19 @@ function useSessionActions({
text={sessionId}
/>
<kit.Separator />
{workItems.map(item => renderMenuItem(kit.Item, item))}
{workItems.map(item => renderActionItem(kit, item))}
{tabCloseItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
{tabCloseItems.map(item => renderActionItem(kit, item))}
</>
)}
<kit.Separator />
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
{dangerItems.map(item => renderActionItem(kit, item))}
{onHideTabBar && (
<>
<kit.Separator />
{renderMenuItem(kit.Item, {
{renderActionItem(kit, {
disabled: false,
icon: 'eye-closed',
label: r.hideTabBar,
@ -443,13 +380,9 @@ function useSessionActions({
}
interface SessionActionsMenuProps
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
extends SessionActions, Pick<React.ComponentProps<typeof ActionsMenu>, '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 (
<>
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={tooltip}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent
align={align}
aria-label={t.sidebar.row.actionsFor(actions.title)}
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
<ActionsMenu
align={align}
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
contentClassName="w-40"
items={renderItems}
sideOffset={sideOffset}
tooltip={tooltip}
>
{children}
</ActionsMenu>
{renameDialog}
</>
)
@ -494,12 +423,13 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
<ActionsContextMenu
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
contentClassName="w-40"
items={renderItems}
>
{children}
</ActionsContextMenu>
{renameDialog}
</>
)

View file

@ -1,25 +1,15 @@
import type * as React from 'react'
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ExternalLink, Eye, EyeOff, KeyRound, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: React.ReactNode
interface EnvVarActions {
clearDisabled?: boolean
docsUrl?: string | null
isRevealed?: boolean
@ -35,21 +25,19 @@ interface EnvVarActionsMenuProps extends Pick<
showReveal?: boolean
}
export function EnvVarActionsMenu({
align = 'end',
children,
// The shared action rows, rendered identically by the kebab dropdown and the
// row's right-click menu so the two never drift.
function useEnvVarItems({
clearDisabled = false,
docsUrl,
isRevealed = false,
isSet,
label,
onClear,
onEdit,
onManageKeys,
onReveal,
showReveal = true,
sideOffset = 6
}: EnvVarActionsMenuProps) {
showReveal = true
}: EnvVarActions) {
const { t } = useI18n()
const copy = t.settings.envActions
const hasClear = isSet && onClear
@ -57,75 +45,117 @@ export function EnvVarActionsMenu({
const hasManageKeys = isSet && onManageKeys
const hasDocs = Boolean(docsUrl?.trim())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={copy.actionsFor(label)} className="w-44" sideOffset={sideOffset}>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}}
>
<ExternalLink className="size-3.5" />
<span>{copy.docs}</span>
</DropdownMenuItem>
)}
return (kit: MenuKit) => {
const rows: ActionItemSpec[] = []
{hasReveal && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onReveal()
}}
>
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
<span>{isRevealed ? copy.hideValue : copy.revealValue}</span>
</DropdownMenuItem>
)}
if (hasDocs) {
rows.push({
iconNode: <ExternalLink className="size-3.5" />,
key: 'docs',
label: copy.docs,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}
})
}
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{isSet ? copy.replace : copy.set}</span>
</DropdownMenuItem>
if (hasReveal) {
rows.push({
iconNode: isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />,
key: 'reveal',
label: isRevealed ? copy.hideValue : copy.revealValue,
onSelect: () => {
triggerHaptic('selection')
onReveal()
}
})
}
{hasManageKeys && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onManageKeys()
}}
>
<KeyRound className="size-3.5" />
<span>{copy.manageInKeys}</span>
</DropdownMenuItem>
)}
rows.push({
icon: 'edit',
key: 'edit',
label: isSet ? copy.replace : copy.set,
onSelect: () => {
triggerHaptic('selection')
onEdit()
}
})
if (hasManageKeys) {
rows.push({
iconNode: <KeyRound className="size-3.5" />,
key: 'manage-keys',
label: copy.manageInKeys,
onSelect: () => {
triggerHaptic('selection')
onManageKeys()
}
})
}
return (
<>
{rows.map(row => renderActionItem(kit, row))}
{hasClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={clearDisabled}
onSelect={() => {
<kit.Separator />
{renderActionItem(kit, {
disabled: clearDisabled,
iconNode: <Trash2 className="size-3.5" />,
key: 'clear',
label: copy.clear,
onSelect: () => {
triggerHaptic('warning')
onClear()
}}
variant="destructive"
>
<Trash2 className="size-3.5" />
<span>{copy.clear}</span>
</DropdownMenuItem>
},
variant: 'destructive'
})}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
}
interface EnvVarActionsMenuProps
extends EnvVarActions, Pick<React.ComponentProps<typeof ActionsMenu>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function EnvVarActionsMenu({ align = 'end', children, sideOffset = 6, ...actions }: EnvVarActionsMenuProps) {
const { t } = useI18n()
const copy = t.settings.envActions
const items = useEnvVarItems(actions)
return (
<ActionsMenu
align={align}
ariaLabel={copy.actionsFor(actions.label)}
contentClassName="w-44"
items={items}
sideOffset={sideOffset}
>
{children}
</ActionsMenu>
)
}
interface EnvVarContextMenuProps extends EnvVarActions {
children: React.ReactNode
}
/** Wrap an env-var row so right-clicking it opens the same menu as its kebab. */
export function EnvVarContextMenu({ children, ...actions }: EnvVarContextMenuProps) {
const { t } = useI18n()
const copy = t.settings.envActions
const items = useEnvVarItems(actions)
return (
<ActionsContextMenu ariaLabel={copy.actionsFor(actions.label)} contentClassName="w-44" items={items}>
{children}
</ActionsContextMenu>
)
}

View file

@ -31,7 +31,7 @@ import type {
ToolsetModelsResponse
} from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { EnvVarActionsMenu, EnvVarActionsTrigger, EnvVarContextMenu } from './env-var-actions-menu'
import { Pill } from './primitives'
import { VoiceProviderFields } from './voice-provider-fields'
@ -150,64 +150,68 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
}
}
const actionProps = {
clearDisabled: busy,
docsUrl: envVar.url,
isRevealed: revealed !== null,
isSet,
label: envVar.key,
onClear: () => void handleClear(),
onEdit: () => setEditing(true),
onManageKeys: openInKeys,
onReveal: () => void handleReveal()
}
return (
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{envVar.key}</span>
<Pill tone={isSet ? 'primary' : 'muted'}>
{isSet && <Check className="size-3" />}
{isSet ? copy.set : copy.notSet}
</Pill>
<EnvVarContextMenu {...actionProps}>
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{envVar.key}</span>
<Pill tone={isSet ? 'primary' : 'muted'}>
{isSet && <Check className="size-3" />}
{isSet ? copy.set : copy.notSet}
</Pill>
</div>
{envVar.prompt && envVar.prompt !== envVar.key && (
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)}
</div>
{envVar.prompt && envVar.prompt !== envVar.key && (
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
{!editing && (
<EnvVarActionsMenu {...actionProps}>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
)}
</div>
{!editing && (
<EnvVarActionsMenu
clearDisabled={busy}
docsUrl={envVar.url}
isRevealed={revealed !== null}
isSet={isSet}
label={envVar.key}
onClear={() => void handleClear()}
onEdit={() => setEditing(true)}
onManageKeys={openInKeys}
onReveal={() => void handleReveal()}
>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
)}
{editing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className="min-w-52 flex-1 font-mono"
onChange={e => setValue(e.target.value)}
placeholder={envVar.prompt || envVar.key}
type={envVar.default ? 'text' : 'password'}
value={value}
/>
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
{t.common.save}
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="text">
{t.common.cancel}
</Button>
</div>
)}
</div>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
)}
{editing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className="min-w-52 flex-1 font-mono"
onChange={e => setValue(e.target.value)}
placeholder={envVar.prompt || envVar.key}
type={envVar.default ? 'text' : 'password'}
value={value}
/>
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
{t.common.save}
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="text">
{t.common.cancel}
</Button>
</div>
)}
</div>
</EnvVarContextMenu>
)
}

View file

@ -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<HTMLDivElement>
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}
>

View file

@ -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 (
<kit.Item
className={className}
disabled={disabled}
key={key ?? (typeof label === 'string' ? label : undefined)}
onSelect={onSelect}
variant={variant}
>
{iconNode ?? (icon ? <Codicon name={icon} size="0.875rem" /> : null)}
{typeof label === 'string' ? <span>{label}</span> : label}
</kit.Item>
)
}
interface ActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, '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 = <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
return (
<DropdownMenu onOpenChange={onOpenChange} open={open}>
{tooltip ? <Tip label={tooltip}>{trigger}</Tip> : trigger}
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
className={contentClassName}
side={side}
sideOffset={sideOffset}
>
{items(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={ariaLabel} className={contentClassName}>
{items(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
)
}