fix(sidebar): make hover title editable

This commit is contained in:
Neil
2026-07-03 19:27:06 -07:00
parent 1282f5c2da
commit ba4db89bc7
7 changed files with 210 additions and 34 deletions
@@ -1816,11 +1816,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
automationHostId={worktree.hostId}
branchName={hoverBranchName}
workspaceTitle={hoverWorkspaceTitle}
workspaceTitleRenameDisabled={isDeleting || affiliateListMode}
detailsAfter={
workspacePorts.length > 0 ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null
}
openDelay={100}
hoverControl={detailsHoverControl}
onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle}
onEditIssue={affiliateListMode ? undefined : handleEditIssue}
onEditComment={affiliateListMode ? undefined : handleEditComment}
onOpenGitHubIssueInOrca={
@@ -0,0 +1,76 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename'
type WorktreeCardHoverIdentityHeaderProps = {
branchName?: string
workspaceTitle?: string
identityOrder: 'workspace-first' | 'branch-first'
workspaceTitleRenameDisabled: boolean
onRenameWorkspaceTitle?: (displayName: string) => Promise<void> | void
onWorkspaceTitleEditingChange?: (editing: boolean) => void
}
export function WorktreeCardHoverIdentityHeader({
branchName,
workspaceTitle,
identityOrder,
workspaceTitleRenameDisabled,
onRenameWorkspaceTitle,
onWorkspaceTitleEditingChange
}: WorktreeCardHoverIdentityHeaderProps): React.JSX.Element | null {
const branchIdentity = branchName ? (
<div
className={cn(
// Why: the hover panel is where users read full git identity; wrap instead
// of truncating so long branch names stay readable like issue titles below.
'break-words font-mono text-[11px] leading-snug text-muted-foreground',
identityOrder === 'workspace-first' && 'mt-1'
)}
>
{branchName}
</div>
) : null
const workspaceIdentity =
workspaceTitle && workspaceTitle !== branchName ? (
onRenameWorkspaceTitle ? (
<WorktreeTitleInlineRename
displayName={workspaceTitle}
disabled={workspaceTitleRenameDisabled}
wrapTitle
className={cn(
'text-[13px] font-semibold leading-snug text-foreground',
identityOrder === 'branch-first' && 'mt-1'
)}
editingClassName={cn(
'w-full text-[13px] leading-snug',
identityOrder === 'branch-first' && 'mt-1'
)}
onEditingChange={onWorkspaceTitleEditingChange}
onRename={onRenameWorkspaceTitle}
/>
) : (
<div
className={cn(
'break-words text-[13px] font-semibold leading-snug text-foreground',
identityOrder === 'branch-first' && 'mt-1'
)}
>
{workspaceTitle}
</div>
)
) : null
if (!branchIdentity && !workspaceIdentity) {
return null
}
return (
// Why: detail sections keep the left rule; the hover title stays flush so
// it reads as the panel heading rather than another inset section.
<div className="min-w-0" data-worktree-hover-identity-header="">
{identityOrder === 'branch-first' ? branchIdentity : workspaceIdentity}
{identityOrder === 'branch-first' ? workspaceIdentity : branchIdentity}
</div>
)
}
@@ -138,6 +138,26 @@ describe('WorktreeCardDetailsHover interactions', () => {
return onUnlinkReview
}
function renderEditableHover(onRenameWorkspaceTitle = vi.fn()): ReturnType<typeof vi.fn> {
container = document.createElement('div')
root = createRoot(container)
act(() => {
root.render(
<WorktreeCardDetailsHover
issue={null}
linearIssue={null}
review={null}
comment={null}
workspaceTitle="Editable hover title"
onRenameWorkspaceTitle={onRenameWorkspaceTitle}
>
<span>Workspace card</span>
</WorktreeCardDetailsHover>
)
})
return onRenameWorkspaceTitle
}
it('defers hover close while the review menu is open', () => {
renderHover()
@@ -177,6 +197,40 @@ describe('WorktreeCardDetailsHover interactions', () => {
expect(container.textContent).not.toContain('More PR actions')
})
it('keeps the hover mounted while the workspace title is being edited', () => {
renderEditableHover()
act(() => {
interactionMocks.onHoverOpenChange?.(true)
})
const title = container.querySelector('[data-worktree-title-inline-rename]')
act(() => {
title?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
})
const input = container.querySelector('[data-worktree-title-rename-input]')
expect(input).not.toBeNull()
act(() => {
interactionMocks.onHoverOpenChange?.(false)
})
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'true'
)
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
})
it('invokes unlink and closes the hover from the menu item', () => {
const onUnlinkReview = renderHover()
@@ -78,6 +78,38 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup.indexOf('feature/local-branch')).toBeLessThan(markup.indexOf('PR #456'))
})
it('keeps the hover title unruled and inline editable while section bodies stay inset', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
branchName="feature/local-branch"
workspaceTitle="Fix stale GH PR"
issue={{
number: 5518,
title: 'Agent monitor lists ephemeral headless subprocesses',
state: 'open',
url: 'https://github.com/acme/orca/issues/5518',
labels: []
}}
linearIssue={null}
review={null}
comment={null}
onRenameWorkspaceTitle={vi.fn()}
onEditIssue={vi.fn()}
onEditComment={vi.fn()}
>
<span>Fix stale GH PR</span>
</WorktreeCardDetailsHover>
)
const identityHeaderTag =
markup.match(/<div[^>]*data-worktree-hover-identity-header=""[^>]*>/)?.[0] ?? ''
expect(identityHeaderTag).not.toContain('border-l')
expect(identityHeaderTag).not.toContain('pl-2')
expect(markup).toContain('data-worktree-title-inline-rename=""')
expect(markup).toContain('Fix stale GH PR')
expect(markup).toContain('border-l border-border/70 pl-3')
})
it('puts unlink behind the first PR actions menu and keeps GitHub last', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
@@ -27,6 +27,7 @@ import { translate } from '@/i18n/i18n'
import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection'
import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection'
import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection'
import { WorktreeCardHoverIdentityHeader } from './WorktreeCardHoverIdentityHeader'
export type {
WorktreeCardIssueDisplay,
@@ -140,10 +141,13 @@ export function WorktreeCardDetailsHover({
branchName,
workspaceTitle,
identityOrder = 'workspace-first',
workspaceTitleRenameDisabled = false,
automationHostId,
detailsAfter,
openDelay = 250,
closeDelay = 120,
onRenameWorkspaceTitle,
onWorkspaceTitleEditingChange,
onEditIssue,
onEditComment,
onOpenGitHubIssueInOrca,
@@ -164,6 +168,30 @@ export function WorktreeCardDetailsHover({
handleReviewMenuOpenChange,
closeHover
} = hoverControl ?? internalHoverControl
const [workspaceTitleEditing, setWorkspaceTitleEditing] = React.useState(false)
const pendingWorkspaceTitleCloseRef = React.useRef(false)
const handleWorkspaceTitleEditingChange = React.useCallback(
(editing: boolean): void => {
setWorkspaceTitleEditing(editing)
onWorkspaceTitleEditingChange?.(editing)
if (!editing && pendingWorkspaceTitleCloseRef.current) {
pendingWorkspaceTitleCloseRef.current = false
handleHoverOpenChange(false)
}
},
[handleHoverOpenChange, onWorkspaceTitleEditingChange]
)
const handleEffectiveHoverOpenChange = React.useCallback(
(next: boolean): void => {
if (!next && workspaceTitleEditing) {
pendingWorkspaceTitleCloseRef.current = true
return
}
pendingWorkspaceTitleCloseRef.current = false
handleHoverOpenChange(next)
},
[handleHoverOpenChange, workspaceTitleEditing]
)
const dismissAndRun = React.useCallback(
(handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
closeHover()
@@ -219,34 +247,10 @@ export function WorktreeCardDetailsHover({
return children
}
const branchIdentity = branchName ? (
<div
className={cn(
// Why: the hover panel is where users read full git identity; wrap instead
// of truncating so long branch names stay readable like issue titles below.
'break-words font-mono text-[11px] leading-snug text-muted-foreground',
identityOrder === 'workspace-first' && 'mt-1'
)}
>
{branchName}
</div>
) : null
const workspaceIdentity =
workspaceTitle && workspaceTitle !== branchName ? (
<div
className={cn(
'break-words text-[13px] font-semibold leading-snug text-foreground',
identityOrder === 'branch-first' && 'mt-1'
)}
>
{workspaceTitle}
</div>
) : null
return (
<HoverCard
open={hoverOpen}
onOpenChange={handleHoverOpenChange}
open={hoverOpen || workspaceTitleEditing}
onOpenChange={handleEffectiveHoverOpenChange}
openDelay={openDelay}
closeDelay={closeDelay}
>
@@ -262,12 +266,14 @@ export function WorktreeCardDetailsHover({
>
<SelectedTextCopyMenu className="space-y-3">
{showIdentityHeader && (
<div className="min-w-0 border-l border-border/70 pl-2">
{/* Why: the closed card no longer carries a branch row; custom-titled
worktrees still need their git branch available in the hover. */}
{identityOrder === 'branch-first' ? branchIdentity : workspaceIdentity}
{identityOrder === 'branch-first' ? workspaceIdentity : branchIdentity}
</div>
<WorktreeCardHoverIdentityHeader
branchName={branchName}
workspaceTitle={workspaceTitle}
identityOrder={identityOrder}
workspaceTitleRenameDisabled={workspaceTitleRenameDisabled}
onRenameWorkspaceTitle={onRenameWorkspaceTitle}
onWorkspaceTitleEditingChange={handleWorkspaceTitleEditingChange}
/>
)}
<WorktreeCardIssueDetailSection
@@ -35,6 +35,7 @@ type WorktreeTitleInlineRenameProps = {
editingClassName?: string
inputClassName?: string
titleWrapper?: (title: React.ReactElement) => React.ReactElement
wrapTitle?: boolean
onEditingChange?: (editing: boolean) => void
onRename: (displayName: string) => Promise<void> | void
// Why: lets a parent (e.g. the workspace.rename shortcut via WorktreeCard)
@@ -53,6 +54,7 @@ export function WorktreeTitleInlineRename({
editingClassName,
inputClassName,
titleWrapper,
wrapTitle = false,
onEditingChange,
onRename,
beginEditing = false,
@@ -288,7 +290,8 @@ export function WorktreeTitleInlineRename({
key={`title:${titleElementKey}`}
ref={handleRootRef}
className={cn(
'block min-w-0 truncate leading-tight focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
'block min-w-0 leading-tight focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
wrapTitle ? 'break-words whitespace-normal' : 'truncate',
titleEmphasisClassName,
className
)}
@@ -310,7 +313,7 @@ export function WorktreeTitleInlineRename({
return titleWrapper(title)
}
if (!titleTruncated) {
if (wrapTitle || !titleTruncated) {
return title
}
@@ -37,10 +37,13 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
branchName?: string
workspaceTitle?: string
identityOrder?: 'workspace-first' | 'branch-first'
workspaceTitleRenameDisabled?: boolean
automationHostId?: ExecutionHostId
detailsAfter?: React.ReactNode
openDelay?: number
closeDelay?: number
onRenameWorkspaceTitle?: (displayName: string) => Promise<void> | void
onWorkspaceTitleEditingChange?: (editing: boolean) => void
onEditIssue?: (event: React.MouseEvent) => void
onEditComment?: (event: React.MouseEvent) => void
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void