Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 17:28:56 -07:00
commit ba4f5b893a
17 changed files with 1467 additions and 567 deletions

View file

@ -3,17 +3,16 @@ 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 +152,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 +249,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,14 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import {
type ActionItemSpec,
ActionsContextMenu,
DROPDOWN_KIT,
type MenuKit,
renderActionItem
} from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
@ -15,7 +21,6 @@ import {
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
@ -28,45 +33,114 @@ import {
setProjectAppearance
} from '@/store/projects'
import { ProjectAppearancePicker } from './project-appearance'
import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
'folder-library',
'repo',
'rocket',
'beaker',
'flame',
'star-full',
'heart',
'zap',
'target',
'lightbulb',
'tools',
'device-desktop',
'device-mobile',
'terminal',
'dashboard',
'globe',
'broadcast',
'cloud',
'database',
'package',
'book',
'organization',
'bug',
'shield',
'key',
'gift',
'telescope',
'home'
]
// Shared per-project state + handlers, so the kebab dropdown and the row's
// right-click menu drive the exact same actions. Modeled on git GUIs (GitHub
// Desktop / GitKraken): reveal in the file manager, copy path, and "Remove from
// sidebar" (never deletes files — auto projects are dismissed, explicit ones
// drop their entry). Explicit projects additionally get rename / add folder /
// set active.
function useProjectActions({
project,
isActive,
scoped,
onExitScope
}: {
project: SidebarProjectTree
isActive: boolean
scoped: boolean
onExitScope?: () => void
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
// — auto projects are dismissed, explicit ones drop their entry). Explicit
// projects additionally get rename / add folder / set active. Hidden until the
// row is hovered (group/workspace), matching the + affordance.
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
// Rename / add folder / set active — explicit projects only (auto ones lack a
// materialized record). Appearance is handled per-surface (popover vs submenu)
// by the caller since its picker chrome differs.
const identityItems: ActionItemSpec[] = project.isAuto
? []
: [
{ icon: 'edit', key: 'rename', label: p.menuRename, onSelect: () => openProjectRename(target) },
{
icon: 'new-folder',
key: 'add-folder',
label: p.menuAddFolder,
onSelect: () => openProjectAddFolder(target)
},
{
disabled: isActive,
icon: 'target',
key: 'set-active',
label: p.menuSetActive,
onSelect: () => void setActiveProject(project.id)
}
]
const pathItems: ActionItemSpec[] = [
{
disabled: !project.path,
icon: 'folder-opened',
key: 'reveal',
label: p.reveal,
onSelect: () => void revealPath(project.path)
},
{
disabled: !project.path,
icon: 'copy',
key: 'copy',
label: p.copyPath,
onSelect: () => void copyPath(project.path)
}
]
const dangerItem: ActionItemSpec = project.isAuto
? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' }
: {
icon: 'trash',
key: 'delete',
label: `${p.menuDelete}`,
onSelect: () => setConfirmDeleteOpen(true),
variant: 'destructive'
}
const confirmDialog = (
<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 +162,17 @@ export function ProjectMenu({
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
isActive,
onExitScope,
project,
scoped
})
// Appearance writes route through the adopt-aware helper: an auto project is
// materialized on its first change (its id then changes), so close the picker
@ -193,42 +256,15 @@ export function ProjectMenu({
)
) : (
<>
<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 +274,86 @@ 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,16 @@ 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 +27,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 +117,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 +162,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 +198,7 @@ function useSessionActions({
]
// IDENTITY — name/mark/reference the session.
const identityItems: ItemSpec[] = [
const identityItems: ActionItemSpec[] = [
spec({
disabled: !sessionId,
icon: 'edit',
@ -270,7 +220,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 +247,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 +298,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 +321,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 +336,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 +346,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 +386,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 +401,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 +429,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,21 @@
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 +31,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 +51,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

@ -555,7 +555,17 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
setSubmitting(true)
aui.composer().send()
// `aui.composer().send()` throws "Composer is not available" when the edit
// composer core has been torn down (e.g. a blur-driven cancel raced the
// click). Reset `submitting` on failure so the arrow can't wedge on `true`
// and leave revert as the only way out (#49903 is the same unguarded-core
// hazard on the main composer).
try {
aui.composer().send()
} catch {
setSubmitting(false)
}
}
const handleEditBlur = useCallback(
@ -590,7 +600,15 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
closeTrigger()
aui.composer().cancel()
// Swallow the unbound-core throw: if the composer core was already torn
// down (a send/cancel raced this timer), cancel() throws "Composer is
// not available" as an uncaught renderer error. Nothing to cancel then.
try {
aui.composer().cancel()
} catch {
// Composer core already gone — the edit is closing anyway.
}
}, 80)
},
[aui, closeTrigger, submitting, syncDraftFromEditor]
@ -786,6 +804,13 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
submitEdit(editor)
}
}}
// Keep focus in the editor on click: macOS doesn't focus a button
// on mousedown, so without this the arrow-click blurs the editor,
// the blur timer cancels the edit (tearing down the composer
// core), and the click's send() then throws against a dead core —
// the edit silently never sends. The restore button guards the
// same way.
onPointerDown={event => event.preventDefault()}
title={copy.sendEdited}
type="button"
>

View file

@ -0,0 +1,151 @@
import { type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
afterEach(() => {
cleanup()
})
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as ThreadMessage
}
function Harness({ onEdit }: { onEdit: (message: AppendMessage) => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread cwd={null} gateway={null} sessionId="session-1" />
</AssistantRuntimeProvider>
)
}
// Regression for the desktop "editing a message, clicking the arrow does
// nothing — I have to click revert" report.
//
// On macOS a <button> does NOT take DOM focus on mousedown, so clicking the
// send arrow blurs the contenteditable (relatedTarget = null). The blur
// schedules an 80ms timer that cancels the edit, tearing down the assistant-ui
// edit-composer core. When the click's send() then runs, and again when the
// blur timer fires, cancel()/send() on a torn-down core throw "Composer is not
// available". The throw wedged the arrow (submitting stuck true) so only revert
// worked.
//
// The blur throw fires from a real setTimeout, which jsdom routes to Node as an
// `uncaughtException` (not a DOM error event), so a window 'error' probe misses
// it. Capture process-level uncaught exceptions for the duration of the gesture
// instead — an unguarded throw registers here and fails the test.
describe('edit send arrow — macOS click gesture (blur races cancel)', () => {
it('sends without an uncaught "Composer is not available" when the arrow-click blurs the editor', async () => {
const uncaught: unknown[] = []
const onUncaught = (err: unknown) => {
uncaught.push(err)
}
process.on('uncaughtException', onUncaught)
try {
const onEdit = vi.fn(async () => {})
render(<Harness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
await act(async () => {
editor.focus()
editor.textContent = 'edited then clicked the arrow'
fireEvent.input(editor)
})
const send = await screen.findByRole('button', { name: 'Send edited message' })
// The real gesture: mousedown on the arrow (no focus on macOS) blurs the
// editor to <body>, then the click fires the send, then the blur's 80ms
// cancel timer runs on the now-torn-down core. Wait past 80ms so the timer
// completes within the captured window.
await act(async () => {
fireEvent.pointerDown(send)
editor.blur()
fireEvent.click(send)
await new Promise(resolve => setTimeout(resolve, 200))
})
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(1)
})
const composerErrors = uncaught.filter(err => /Composer is not available/.test(String(err)))
expect(composerErrors).toEqual([])
} finally {
process.off('uncaughtException', onUncaught)
}
})
})

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,188 @@
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>
)
}

View file

@ -2150,6 +2150,12 @@ def connect(
return conn
with _cross_process_init_lock(path):
# Read-only file/sidecar preflight (port of kilocode#12508) —
# repair-or-refuse before the header/integrity probes so a stray
# read-only kanban.db fails with an actionable message instead of
# "attempt to write a readonly database" mid-init.
from hermes_state import preflight_db_writability
preflight_db_writability(path, db_label=f"kanban.db ({path.name})")
# Cheap byte-level check first — catches the #29507 TLS-overwrite shape
# and other invalid-header cases without opening a sqlite connection.
_validate_sqlite_header(path)

View file

@ -846,6 +846,96 @@ def _backup_db_file(db_path: Path) -> Optional[Path]:
return None
def preflight_db_writability(
db_path: Path,
*,
db_label: str = "state.db",
) -> None:
"""Refuse-or-repair read-only DB files BEFORE the first connection opens.
Port of Kilo-Org/kilocode#12508's startup preflight. A stray read-only
``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored backup, copied
dotfiles) previously surfaced as an opaque
``sqlite3.OperationalError: attempt to write a readonly database`` raised
from deep inside ``_init_schema`` naming no file and no fix and the
obvious wrong "fix" (deleting the ``-wal``) silently loses committed
transactions. This preflight:
- **Repairs** permissions with ``chmod u+rw`` when the file lives inside
the Hermes home tree (``get_hermes_home()``) the safe repair scope:
Hermes owns those files, and the OS makes ``chmod`` fail on files the
user doesn't own, which bounds the repair exactly.
- **Fails fast with an actionable error** naming the exact file and the
exact ``chmod`` command for anything else (root-owned files, read-only
mounts, custom paths outside the home tree).
- Never deletes or truncates a WAL sidecar once writable, the normal
open path checkpoints its committed frames into the DB as intended.
``:memory:`` and ``file:`` URI paths are skipped (no plain on-disk files
to check). Shared by :class:`SessionDB` and ``hermes_cli.kanban_db``.
"""
raw = str(db_path)
if raw == ":memory:" or raw.startswith("file:"):
return
try:
home: Optional[Path] = Path(get_hermes_home()).resolve()
except Exception: # pragma: no cover - defensive
home = None
def _in_repair_scope(p: Path) -> bool:
if home is None:
return False
try:
return p.resolve().is_relative_to(home)
except (OSError, ValueError):
return False
def _ensure_writable(p: Path, *, is_dir: bool = False) -> None:
import stat as _stat
if os.access(p, os.R_OK | os.W_OK):
return
if _in_repair_scope(p):
try:
add = _stat.S_IRUSR | _stat.S_IWUSR | (_stat.S_IXUSR if is_dir else 0)
os.chmod(p, p.stat().st_mode | add)
except OSError:
pass
if os.access(p, os.R_OK | os.W_OK):
logger.info(
"%s preflight: repaired read-only %s (chmod u+rw%s)",
db_label,
p,
"x" if is_dir else "",
)
return
kind = "directory" if is_dir else "file"
wal_note = (
" Do NOT delete the -wal file — it contains committed data that "
"will be merged into the database once it is writable."
if p.name.endswith("-wal")
else ""
)
raise sqlite3.OperationalError(
f"{db_label} is not writable: {kind} {p} is read-only for this "
f"user. Hermes needs read-write access to open the database. "
f"Fix with: chmod u+rw{'x' if is_dir else ''} '{p}'"
f" (files owned by another user may need sudo/chown).{wal_note}"
)
parent = db_path.parent
if parent.is_dir():
# SQLite needs a writable directory in every journal mode (WAL and
# SHM sidecars in WAL mode; the rollback journal in DELETE mode).
_ensure_writable(parent, is_dir=True)
for suffix in ("", "-wal", "-shm"):
p = db_path.with_name(db_path.name + suffix) if suffix else db_path
if p.is_file():
_ensure_writable(p)
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
@ -1948,6 +2038,13 @@ class SessionDB:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# Read-only file/sidecar preflight (port of kilocode#12508):
# repair-or-refuse BEFORE the first connection so users get an
# actionable message instead of an opaque "attempt to write a
# readonly database" from deep inside _init_schema.
if not read_only:
preflight_db_writability(self.db_path, db_label="state.db")
# #68474: zeroed state.db (size>0, all-NUL header) used to fail as a
# generic "file is not a database" with no recovery path. Quarantine
# the bytes (do not delete) and continue so a fresh DB can open;

View file

@ -0,0 +1,214 @@
"""Tests for the read-only DB preflight (port of Kilo-Org/kilocode#12508).
A stray read-only ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored
backup, copied dotfiles) used to surface as an opaque
``sqlite3.OperationalError: attempt to write a readonly database`` raised
from deep inside ``_init_schema`` naming no file and no fix.
``preflight_db_writability`` now runs before the first connection:
- files inside the Hermes home tree are repaired with ``chmod u+rw``
(the safe scope chmod fails on files the user doesn't own);
- anything else fails fast with an error naming the exact file and the
exact ``chmod`` command;
- WAL sidecars are never deleted, so committed frames survive repair.
"""
import os
import sqlite3
import stat
import sys
from pathlib import Path
import pytest
import hermes_state
from hermes_state import SessionDB, preflight_db_writability
pytestmark = [
pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics"),
pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root bypasses file permission checks",
),
]
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so the repair scope covers tmp DBs."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _make_db(path: Path) -> None:
conn = sqlite3.connect(str(path))
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
conn.close()
def _make_wal_db(path: Path) -> None:
"""Create a WAL-mode DB with committed-but-uncheckpointed frames."""
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE t (x)")
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
# Keep a READ-ONLY second connection open so neither close can
# checkpoint: the writer skips checkpoint-on-close because another
# connection exists, and the ro holder cannot checkpoint at all.
# The committed row therefore lives only in the -wal file.
holder = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
holder.execute("SELECT 1").fetchone()
conn.execute("INSERT INTO t VALUES (42)")
conn.commit()
conn.close()
holder.close()
assert path.with_name(path.name + "-wal").is_file(), (
"fixture precondition: -wal sidecar must survive with pending frames"
)
class TestRepairScope:
def test_repairs_readonly_db_inside_home(self, hermes_home):
db = hermes_home / "state.db"
_make_db(db)
os.chmod(db, 0o444)
preflight_db_writability(db, db_label="state.db")
assert os.access(db, os.W_OK)
def test_repairs_readonly_sidecars(self, hermes_home):
db = hermes_home / "state.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
assert wal.is_file(), "fixture must leave a -wal behind"
os.chmod(db, 0o444)
os.chmod(wal, 0o444)
preflight_db_writability(db, db_label="state.db")
assert os.access(db, os.W_OK)
assert os.access(wal, os.W_OK)
def test_wal_data_survives_repair(self, hermes_home):
"""The committed WAL frame must be readable after repair — proof the
preflight never drops/truncates a sidecar."""
db = hermes_home / "state.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
os.chmod(db, 0o444)
os.chmod(wal, 0o444)
preflight_db_writability(db, db_label="state.db")
conn = sqlite3.connect(str(db))
rows = conn.execute("SELECT x FROM t").fetchall()
conn.close()
assert (42,) in rows
def test_repairs_readonly_parent_directory(self, hermes_home):
sub = hermes_home / "kanban"
sub.mkdir()
db = sub / "kanban.db"
_make_db(db)
os.chmod(sub, 0o555)
try:
preflight_db_writability(db, db_label="kanban.db")
assert os.access(sub, os.W_OK)
finally:
os.chmod(sub, 0o755)
class TestRefusalOutsideScope:
def test_actionable_error_names_file_and_chmod(self, hermes_home, tmp_path):
outside = tmp_path / "elsewhere"
outside.mkdir()
db = outside / "custom.db"
_make_db(db)
os.chmod(db, 0o444)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
preflight_db_writability(db, db_label="custom.db")
msg = str(exc_info.value)
assert str(db) in msg
assert "chmod u+rw" in msg
# Must NOT have silently chmod'd a file outside the home tree.
assert not os.access(db, os.W_OK)
finally:
os.chmod(db, 0o644)
def test_wal_error_warns_against_deletion(self, hermes_home, tmp_path):
outside = tmp_path / "elsewhere"
outside.mkdir()
db = outside / "custom.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
os.chmod(wal, 0o444)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
preflight_db_writability(db, db_label="custom.db")
msg = str(exc_info.value)
assert str(wal) in msg
assert "Do NOT delete" in msg
finally:
os.chmod(wal, 0o644)
class TestSkips:
def test_memory_uri_skipped(self, hermes_home):
preflight_db_writability(Path(":memory:"))
def test_file_uri_skipped(self, hermes_home):
preflight_db_writability(Path("file:whatever?mode=ro"))
def test_missing_files_no_error(self, hermes_home):
preflight_db_writability(hermes_home / "state.db")
def test_healthy_db_untouched(self, hermes_home):
db = hermes_home / "state.db"
_make_db(db)
before = stat.S_IMODE(db.stat().st_mode)
preflight_db_writability(db)
assert stat.S_IMODE(db.stat().st_mode) == before
class TestSessionDBIntegration:
def test_sessiondb_selfheals_readonly_db_in_home(self, hermes_home):
db_path = hermes_home / "state.db"
first = SessionDB(db_path)
first.close()
for suffix in ("", "-wal", "-shm"):
p = db_path.with_name(db_path.name + suffix)
if p.is_file():
os.chmod(p, 0o444)
db = SessionDB(db_path) # must not raise "readonly database"
try:
assert os.access(db_path, os.W_OK)
finally:
db.close()
def test_sessiondb_actionable_error_outside_home(
self, hermes_home, tmp_path
):
outside = tmp_path / "custom-loc"
outside.mkdir()
db_path = outside / "state.db"
first = SessionDB(db_path)
first.close()
os.chmod(db_path, 0o444)
hermes_state._set_last_init_error(None)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
SessionDB(db_path)
msg = str(exc_info.value)
assert str(db_path) in msg
assert "chmod" in msg
finally:
os.chmod(db_path, 0o644)
hermes_state._set_last_init_error(None)