Revert agent hovercard (#2460)

This commit is contained in:
Neil 2026-05-20 21:52:29 -07:00 committed by GitHub
parent a6075e06c8
commit 20099eb82e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 21 additions and 416 deletions

View File

@ -1,13 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
const { writeTextMock } = vi.hoisted(() => ({
writeTextMock: vi.fn()
}))
vi.mock('electron', () => ({
clipboard: { writeText: writeTextMock }
}))
import { buildEditableContextMenuTemplate } from './editable-context-menu'
import { richMarkdownContextMenuCommandChannel } from '../../shared/rich-markdown-context-menu'
@ -148,31 +139,6 @@ describe('buildEditableContextMenuTemplate', () => {
).toEqual([])
})
it('builds a native read-only text menu for selected markdown text and links', () => {
const template = buildEditableContextMenuTemplate(
contextParams({
isEditable: false,
linkURL: 'https://example.com/docs',
selectionText: 'https://example.com/docs'
}),
{
replaceMisspelling: vi.fn(),
send: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
)
expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([
'Copy URL',
'separator',
'copy',
'selectAll'
])
template[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(writeTextMock).toHaveBeenCalledWith('https://example.com/docs')
})
it('keeps regular text inputs to spelling and native edit actions', () => {
const template = buildEditableContextMenuTemplate(
contextParams({ formControlType: 'input-text' }),

View File

@ -1,4 +1,3 @@
import { clipboard } from 'electron'
import {
richMarkdownContextMenuCommandChannel,
type RichMarkdownContextMenuCommand,
@ -84,34 +83,12 @@ function buildNativeEditMenuTemplate(): Electron.MenuItemConstructorOptions[] {
]
}
function buildReadOnlyTextMenuTemplate(
params: Electron.ContextMenuParams
): Electron.MenuItemConstructorOptions[] {
const template: Electron.MenuItemConstructorOptions[] = []
if (params.linkURL) {
template.push({
label: 'Copy URL',
click: () => clipboard.writeText(params.linkURL)
})
}
if (params.selectionText) {
if (template.length > 0) {
template.push({ type: 'separator' })
}
template.push({ role: 'copy' })
}
if (template.length > 0) {
template.push({ role: 'selectAll' })
}
return template
}
export function buildEditableContextMenuTemplate(
params: Electron.ContextMenuParams,
webContents: EditableContextMenuWebContents
): Electron.MenuItemConstructorOptions[] {
if (!params.isEditable) {
return buildReadOnlyTextMenuTemplate(params)
return []
}
const suggestions = params.dictionarySuggestions.slice(0, 5)

View File

@ -1,73 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { DashboardAgentHoverCardContent } from './DashboardAgentHoverCardContent'
import type { DashboardAgentRow } from './useDashboardData'
function makeAgentRow(): DashboardAgentRow {
const now = Date.UTC(2026, 4, 20)
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const prompt = 'Review **markdown** formatting'
return {
paneKey,
agentType: 'codex',
state: 'working',
startedAt: now - 60_000,
tab: {
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: now
},
entry: {
state: 'working',
prompt,
updatedAt: now,
stateStartedAt: now - 60_000,
agentType: 'codex',
paneKey,
stateHistory: [{ state: 'working', prompt, startedAt: now - 60_000 }],
toolName: 'Bash',
toolInput: 'pnpm test',
lastAssistantMessage: 'Rendered **markdown** in the hover card.'
}
}
}
describe('DashboardAgentHoverCardContent', () => {
it('renders prompt and assistant markdown in structured sections', () => {
const markup = renderToStaticMarkup(
<TooltipProvider>
<DashboardAgentHoverCardContent
agent={makeAgentRow()}
dotState="working"
prompt="Review **markdown** formatting"
isWorking
toolName="Bash"
toolInput="pnpm test"
lastAssistantMessage="Rendered **markdown** in the hover card."
headerTimestamp="1m ago"
onActivate={() => {}}
/>
</TooltipProvider>
)
expect(markup).toContain('Prompt')
expect(markup).toContain('Current tool')
expect(markup).toContain('Latest message')
expect(markup).toContain('Copy prompt')
expect(markup).toContain('Copy latest message')
expect(markup).toContain('data-slot="scroll-area"')
expect(markup).toContain('data-slot="scroll-area-viewport"')
expect(markup).toContain('whitespace-pre')
expect(markup).toContain('[&amp;_pre]:max-w-none')
expect(markup).not.toContain('overflow-auto')
expect(markup).toContain('<strong>markdown</strong>')
expect(markup).toContain('pnpm test')
})
})

View File

@ -1,218 +0,0 @@
import React from 'react'
import { ArrowRight, Check, Copy } from 'lucide-react'
import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { WORKTREE_CONTEXT_MENU_SCOPE_ATTR } from '@/components/sidebar/worktree-context-menu-scope'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
type AgentHoverSectionProps = {
title: string
children: React.ReactNode
copyLabel?: string
copied?: boolean
onCopy?: (event: React.MouseEvent<HTMLButtonElement>) => void
}
function AgentHoverSection({
title,
children,
copyLabel,
copied = false,
onCopy
}: AgentHoverSectionProps): React.JSX.Element {
return (
<section className="space-y-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{title}
</div>
{onCopy && copyLabel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 text-muted-foreground hover:text-foreground"
onClick={onCopy}
onMouseDown={(event) => event.stopPropagation()}
aria-label={copyLabel}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{copied ? 'Copied' : copyLabel}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-[12px] leading-relaxed text-popover-foreground">{children}</div>
</section>
)
}
type DashboardAgentHoverCardContentProps = {
agent: DashboardAgentRowData
dotState: AgentDotState
prompt: string
isWorking: boolean
toolName: string
toolInput: string
lastAssistantMessage: string
headerTimestamp: string | null
onActivate: (event: React.SyntheticEvent) => void
}
export function DashboardAgentHoverCardContent({
agent,
dotState,
prompt,
isWorking,
toolName,
toolInput,
lastAssistantMessage,
headerTimestamp,
onActivate
}: DashboardAgentHoverCardContentProps): React.JSX.Element {
const copyResetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const [copiedSection, setCopiedSection] = React.useState<'prompt' | 'latest' | null>(null)
const agentLabel = formatAgentTypeLabel(agent.agentType)
const hasToolDetails = isWorking && (toolName.length > 0 || toolInput.length > 0)
const hasBodyDetails = prompt.length > 0 || hasToolDetails || lastAssistantMessage.length > 0
const copySectionText = React.useCallback((section: 'prompt' | 'latest', text: string) => {
return async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
await window.api.ui.writeClipboardText(text)
setCopiedSection(section)
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
copyResetTimerRef.current = setTimeout(() => setCopiedSection(null), 1200)
}
}, [])
const handleHeaderKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
event.preventDefault()
event.stopPropagation()
onActivate(event)
},
[onActivate]
)
React.useEffect(() => {
return () => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
}
}, [])
return (
<div
className="flex max-h-[min(70vh,520px)] flex-col"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onContextMenuCapture={(event) => event.stopPropagation()}
{...{ [WORKTREE_CONTEXT_MENU_SCOPE_ATTR]: 'agent-hover-card' }}
>
<div
role="button"
tabIndex={0}
className="group/header flex h-10 shrink-0 cursor-pointer items-center border-b border-border/60 bg-popover px-3 text-left transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={onActivate}
onKeyDown={handleHeaderKeyDown}
>
<div className="flex min-w-0 w-full items-center gap-2">
<AgentStateDot state={dotState} size="md" />
<span className="inline-flex size-4 shrink-0 items-center justify-center text-foreground">
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={16} />
</span>
<div className="min-w-0 flex-1 truncate text-sm font-semibold leading-4 text-popover-foreground">
{agentLabel}
</div>
{agent.entry.interrupted && (
<span className="shrink-0 rounded-sm bg-destructive/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive">
interrupted
</span>
)}
<span className="ml-auto grid shrink-0 grid-cols-1 grid-rows-1 items-center justify-items-end">
{headerTimestamp && (
<span className="[grid-area:1/1] text-xs font-normal text-muted-foreground transition-opacity group-hover/header:opacity-0">
{headerTimestamp}
</span>
)}
<span className="[grid-area:1/1] inline-flex items-center gap-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity group-hover/header:opacity-100">
<ArrowRight className="size-3" />
Open
</span>
</span>
</div>
</div>
<ScrollArea
className="min-h-0 max-h-[calc(min(70vh,520px)-2.5rem)] flex-1"
viewportClassName="max-h-[calc(min(70vh,520px)-2.5rem)]"
type="auto"
scrollbars="both"
>
<div className="min-w-full space-y-3 px-3 py-3 [&_pre]:max-w-none [&_table]:min-w-max">
{prompt && (
<AgentHoverSection
title="Prompt"
copyLabel="Copy prompt"
copied={copiedSection === 'prompt'}
onCopy={copySectionText('prompt', prompt)}
>
<CommentMarkdown
content={prompt}
variant="document"
className="text-[12px] leading-relaxed"
/>
</AgentHoverSection>
)}
{hasToolDetails && (
<AgentHoverSection title="Current tool">
<div className="space-y-1.5">
{toolName && (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[11px] text-accent-foreground">
{toolName}
</code>
)}
{toolInput && (
<pre className="whitespace-pre rounded-md bg-accent p-2 font-mono text-[11px] leading-snug text-accent-foreground">
{toolInput}
</pre>
)}
</div>
</AgentHoverSection>
)}
{lastAssistantMessage && (
<AgentHoverSection
title="Latest message"
copyLabel="Copy latest message"
copied={copiedSection === 'latest'}
onCopy={copySectionText('latest', lastAssistantMessage)}
>
<CommentMarkdown
content={lastAssistantMessage}
variant="document"
className="text-[12px] leading-relaxed"
/>
</AgentHoverSection>
)}
{!hasBodyDetails && (
<div className="text-xs text-muted-foreground">No agent details yet.</div>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@ -5,8 +5,6 @@ import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { DashboardAgentHoverCardContent } from './DashboardAgentHoverCardContent'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
@ -117,7 +115,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
hideExpand = false
}: Props) {
const [expanded, setExpanded] = useState(false)
const [hoverCardOpen, setHoverCardOpen] = useState(false)
// Why: stop propagation so clicking the X doesn't also fire the worktree
// card's click handler (which navigates away from the dashboard).
const handleDismiss = useCallback(
@ -160,25 +157,16 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
},
[onActivate, agent.tab.id, agent.paneKey]
)
const handleHoverCardActivate = useCallback(
(e: React.SyntheticEvent) => {
e.stopPropagation()
setHoverCardOpen(false)
onActivate(agent.tab.id, agent.paneKey)
},
[onActivate, agent.tab.id, agent.paneKey]
)
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
const prompt = agent.entry.prompt.trim()
const dotState = asDotState(agent.state)
// Why: `agent.entry.prompt` is normalized to '' when the prompt is unknown
// (fresh agent, missing telemetry). Rendering the row with an empty primary
// slot would collapse the text column and leave the row with no human-
// readable label — just a state dot and icon. Fall back to the state label
// ("Working", "Done", "Waiting", …) so every row is identifiable at a
// glance.
const displayLabel = prompt || agentStateLabel(dotState)
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
// Why: the tool row describes what the agent is *currently* doing; once it
// leaves working, that line goes stale and misleads (a done row showing
// "Bash: pnpm test" reads as if the command is still running). Gate tool
@ -202,17 +190,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
if (doneAt !== null) {
tsParts.push(`done ${formatTimeAgo(doneAt, now)}`)
}
const headerTimestamp =
doneAt !== null
? formatTimeAgo(doneAt, now)
: startedAt !== null
? formatTimeAgo(startedAt, now)
: null
const row = (
return (
// Why: NOT role="button" / tabIndex={0}. The row contains real <button>
// children (dismiss X, expand chevron) and is itself the hover-card
// trigger — nesting them inside an outer
// children (dismiss X, expand chevron) and tooltip triggers that forward
// button semantics to their children — nesting them inside an outer
// role=button violates ARIA's "no interactive content inside interactive
// content" rule and breaks keyboard/AT navigation. Keyboard users reach
// the agent via the child buttons and the tab switcher; the outer <div>
@ -229,6 +211,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// active-state pattern) so the lift is symmetric across themes.
'cursor-pointer rounded-sm hover:bg-black/[0.06] dark:hover:bg-accent/30'
)}
title={tsParts.length > 0 ? tsParts.join(' • ') : undefined}
>
<div className="flex items-center gap-1.5">
{/* Why: state indicator lives in the leading gutter so the user's
@ -240,9 +223,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
overpowering the prompt text. */}
<span
className="inline-flex shrink-0 items-center justify-center"
aria-label={agent.entry.interrupted ? 'Interrupted' : agentStateLabel(dotState)}
title={agent.entry.interrupted ? 'Interrupted' : agentStateLabel(asDotState(agent.state))}
>
<AgentStateDot state={dotState} size={stateDotSize} />
<AgentStateDot state={asDotState(agent.state)} size={stateDotSize} />
</span>
{/* Why: identity (Claude/Codex/Gemini/) sits inline with the prompt
so the reader gets "state → who → what they said" left-to-right
@ -251,7 +234,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
them keeping the icon only on the prompt row lets the sub-rows
indent under the prompt text cleanly. */}
{!hideIdentityIcon && (
<span className="inline-flex shrink-0" aria-label={formatAgentTypeLabel(agent.agentType)}>
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={14} />
</span>
)}
@ -413,7 +396,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
<Wrench className="size-2.5 shrink-0" />
<code className="shrink-0 font-mono text-[10px]">{toolName}</code>
{!expanded && toolInput && (
<span className="min-w-0 truncate text-muted-foreground/60">{toolInput}</span>
<span className="min-w-0 truncate text-muted-foreground/60" title={toolInput}>
{toolInput}
</span>
)}
</div>
{/* Why: grid-rows [0fr][1fr] is the CSS-only height animation
@ -474,6 +459,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
)}
title={!expanded ? lastAssistantMessage : undefined}
/>
) : (
!expanded && (
@ -482,35 +468,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
)}
</div>
)
return (
<HoverCard
open={hoverCardOpen}
onOpenChange={setHoverCardOpen}
openDelay={250}
closeDelay={120}
>
<HoverCardTrigger asChild>{row}</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
sideOffset={10}
className="w-96 max-w-[calc(100vw-2rem)] p-0"
>
<DashboardAgentHoverCardContent
agent={agent}
dotState={dotState}
prompt={prompt}
isWorking={isWorking}
toolName={toolName}
toolInput={toolInput}
lastAssistantMessage={lastAssistantMessage}
headerTimestamp={headerTimestamp}
onActivate={handleHoverCardActivate}
/>
</HoverCardContent>
</HoverCard>
)
})
export default DashboardAgentRow

View File

@ -39,7 +39,6 @@ import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedSc
import { getLineageRenderInfo } from './worktree-list-groups'
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { WORKTREE_CONTEXT_MENU_SCOPE_ATTR } from './worktree-context-menu-scope'
type Props = {
worktree: Worktree
@ -51,6 +50,7 @@ type Props = {
}
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
const WORKTREE_CONTEXT_MENU_SCOPE_ATTR = 'data-worktree-context-menu-scope'
const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500
function shouldIgnoreNestedWorktreeContextMenuScope(
@ -427,10 +427,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
>
{!isMultiContext && (
<>
<DropdownMenuItem onSelect={handleRename} disabled={isDeleting}>
<Pencil className="size-3.5" />
Edit details
</DropdownMenuItem>
<WorktreeOpenInSubMenu
worktreePath={worktree.path}
connectionId={repo?.connectionId ?? null}
@ -507,6 +503,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{!isMultiContext && (
<DropdownMenuItem onSelect={handleRename} disabled={isDeleting}>
<Pencil className="size-3.5" />
Update
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<Tooltip>
<TooltipTrigger asChild>

View File

@ -1 +0,0 @@
export const WORKTREE_CONTEXT_MENU_SCOPE_ATTR = 'data-worktree-context-menu-scope'

View File

@ -8,7 +8,6 @@ function ScrollArea({
viewportClassName,
viewportRef,
viewportTabIndex,
scrollbars = 'vertical',
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
@ -16,7 +15,6 @@ function ScrollArea({
viewportRef?: React.Ref<HTMLDivElement>
/** Set e.g. -1 so the viewport can receive programmatic focus (explorer keyboard shortcuts after inline rename). */
viewportTabIndex?: number
scrollbars?: 'vertical' | 'horizontal' | 'both'
}) {
return (
<ScrollAreaPrimitive.Root
@ -35,10 +33,7 @@ function ScrollArea({
>
{children}
</ScrollAreaPrimitive.Viewport>
{(scrollbars === 'vertical' || scrollbars === 'both') && <ScrollBar />}
{(scrollbars === 'horizontal' || scrollbars === 'both') && (
<ScrollBar orientation="horizontal" />
)}
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)