mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
This commit is contained in:
commit
ba4f5b893a
17 changed files with 1467 additions and 567 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
188
apps/desktop/src/components/ui/actions-menu.tsx
Normal file
188
apps/desktop/src/components/ui/actions-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
214
tests/test_hermes_state_readonly_preflight.py
Normal file
214
tests/test_hermes_state_readonly_preflight.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue