mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
fix(sidebar): make hover title editable
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user