From ba4db89bc79601207a0c05888b388eceac1e666c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:18:11 -0700 Subject: [PATCH] fix(sidebar): make hover title editable --- .../src/components/sidebar/WorktreeCard.tsx | 2 + .../WorktreeCardHoverIdentityHeader.tsx | 76 +++++++++++++++++++ .../WorktreeCardMeta.interaction.test.tsx | 54 +++++++++++++ .../sidebar/WorktreeCardMeta.test.tsx | 32 ++++++++ .../components/sidebar/WorktreeCardMeta.tsx | 70 +++++++++-------- .../sidebar/WorktreeTitleInlineRename.tsx | 7 +- .../sidebar/worktree-card-meta-types.ts | 3 + 7 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index bb975bd2158..3a20d2a5d28 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1816,11 +1816,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ automationHostId={worktree.hostId} branchName={hoverBranchName} workspaceTitle={hoverWorkspaceTitle} + workspaceTitleRenameDisabled={isDeleting || affiliateListMode} detailsAfter={ workspacePorts.length > 0 ? : null } openDelay={100} hoverControl={detailsHoverControl} + onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} onEditIssue={affiliateListMode ? undefined : handleEditIssue} onEditComment={affiliateListMode ? undefined : handleEditComment} onOpenGitHubIssueInOrca={ diff --git a/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx b/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx new file mode 100644 index 00000000000..f8994e7c66d --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx @@ -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 + onWorkspaceTitleEditingChange?: (editing: boolean) => void +} + +export function WorktreeCardHoverIdentityHeader({ + branchName, + workspaceTitle, + identityOrder, + workspaceTitleRenameDisabled, + onRenameWorkspaceTitle, + onWorkspaceTitleEditingChange +}: WorktreeCardHoverIdentityHeaderProps): React.JSX.Element | null { + const branchIdentity = branchName ? ( +
+ {branchName} +
+ ) : null + const workspaceIdentity = + workspaceTitle && workspaceTitle !== branchName ? ( + onRenameWorkspaceTitle ? ( + + ) : ( +
+ {workspaceTitle} +
+ ) + ) : 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. +
+ {identityOrder === 'branch-first' ? branchIdentity : workspaceIdentity} + {identityOrder === 'branch-first' ? workspaceIdentity : branchIdentity} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 7464b6f5477..b53ea2de14b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -138,6 +138,26 @@ describe('WorktreeCardDetailsHover interactions', () => { return onUnlinkReview } + function renderEditableHover(onRenameWorkspaceTitle = vi.fn()): ReturnType { + container = document.createElement('div') + root = createRoot(container) + act(() => { + root.render( + + Workspace card + + ) + }) + 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() diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 895efe1151a..a358918c2e1 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -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( + + Fix stale GH PR + + ) + const identityHeaderTag = + markup.match(/]*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( { + 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 ? ( -
- {branchName} -
- ) : null - const workspaceIdentity = - workspaceTitle && workspaceTitle !== branchName ? ( -
- {workspaceTitle} -
- ) : null - return ( @@ -262,12 +266,14 @@ export function WorktreeCardDetailsHover({ > {showIdentityHeader && ( -
- {/* 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} -
+ )} React.ReactElement + wrapTitle?: boolean onEditingChange?: (editing: boolean) => void onRename: (displayName: string) => Promise | 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 } diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index f0393848db6..faf61492483 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -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 + onWorkspaceTitleEditingChange?: (editing: boolean) => void onEditIssue?: (event: React.MouseEvent) => void onEditComment?: (event: React.MouseEvent) => void onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void