Improve child workspace card UI (#2213)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-17 21:24:03 -07:00 committed by GitHub
parent 956a6a2b03
commit c78b8bde55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 69 deletions

View File

@ -40,6 +40,7 @@ type WorktreeCardProps = {
worktree: Worktree
repo: Repo | undefined
isActive: boolean
isActiveSurface?: boolean
isMultiSelected?: boolean
selectedWorktrees?: readonly Worktree[]
hideRepoBadge?: boolean
@ -64,6 +65,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
isActive,
isActiveSurface = isActive,
isMultiSelected = false,
selectedWorktrees,
onActivate,
@ -381,12 +383,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
className={cn(
'group relative flex items-start gap-1.5 px-1.5 py-1.5 cursor-pointer transition-all duration-200 outline-none select-none ml-1',
isMultiSelected ? 'rounded-sm' : 'rounded-lg',
isActive
isActiveSurface
? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: isMultiSelected
? 'border border-sidebar-ring/35 bg-sidebar-accent/70 ring-1 ring-sidebar-ring/30'
: 'border border-transparent hover:bg-sidebar-accent/40',
isActive && isMultiSelected && 'ring-1 ring-sidebar-ring/35',
isActiveSurface && isMultiSelected && 'ring-1 ring-sidebar-ring/35',
!nativeDragEnabled && !isDeleting && '!cursor-grab',
isDeleting && 'opacity-50 grayscale cursor-not-allowed',
isSshDisconnected && !isDeleting && 'opacity-60'

View File

@ -14,6 +14,7 @@ import {
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import WorktreeCardAgents from './WorktreeCardAgents'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@ -126,6 +127,10 @@ function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent<HTMLElement>):
}
}
function stopNestedWorktreeCardBubble(event: React.SyntheticEvent<HTMLElement>): void {
event.stopPropagation()
}
function getWorktreeOptionId(worktreeId: string): string {
return `worktree-list-option-${encodeURIComponent(worktreeId)}`
}
@ -136,7 +141,6 @@ type VirtualizedWorktreeViewportProps = {
rows: Row[]
activeWorktreeId: string | null
groupBy: WorktreeGroupBy
showInlineAgentCards: boolean
repoGroupOrdering: RepoGroupOrdering
toggleGroup: (key: string) => void
collapsedGroups: Set<string>
@ -169,6 +173,7 @@ type VirtualizedWorktreeViewportProps = {
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onPinWorktree: (worktreeId: string) => void
onPinWorktrees: (worktreeIds: readonly string[]) => void
showInlineAgentCards: boolean
// Why: broad grouping changes still remount the viewport, while add/delete
// stays mounted for row-key anchoring and layout animation. These refs bridge
// both paths so the virtualizer never falls back to scrollTop 0.
@ -262,7 +267,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
rows,
activeWorktreeId,
groupBy,
showInlineAgentCards,
repoGroupOrdering,
toggleGroup,
collapsedGroups,
@ -288,6 +292,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onMoveWorktreesToStatus,
onPinWorktree,
onPinWorktrees,
showInlineAgentCards,
scrollOffsetRef,
scrollAnchorRef
}: VirtualizedWorktreeViewportProps) {
@ -295,6 +300,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const suppressMeasurementAdjustmentUntilRef = useRef(0)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState<string | null>(null)
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
// Drag is only meaningful when repo headers are using manual order. The
@ -309,6 +315,33 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
() => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)),
[renderRows, activeWorktreeId]
)
const activeLineageChildRow = useMemo(() => {
if (activeWorktreeId === null) {
return null
}
for (const row of renderRows) {
if (row.type !== 'lineage-group') {
continue
}
const child = row.rows.slice(1).find((item) => item.worktree.id === activeWorktreeId)
if (child) {
return child
}
}
return null
}, [activeWorktreeId, renderRows])
const activeLineageChildWorktreeId = activeLineageChildRow?.worktree.id ?? null
const activeLineageChildConnectionId = activeLineageChildRow?.repo?.connectionId ?? null
const activeLineageChildSshStatus = useAppStore((s) =>
activeLineageChildConnectionId
? (s.sshConnectionStates.get(activeLineageChildConnectionId)?.status ?? 'disconnected')
: null
)
const activeLineageChildTargetLabel = useAppStore((s) =>
activeLineageChildConnectionId ? s.sshTargetLabels.get(activeLineageChildConnectionId) : null
)
const activeLineageChildSshDisconnected =
activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected'
const renderRowsRef = useRef(renderRows)
renderRowsRef.current = renderRows
const getVirtualItemKey = useCallback(
@ -639,6 +672,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [activeModal, navigateWorktree])
// Why: lightweight nested cards do not mount WorktreeCard, so the viewport
// owns the SSH reconnect prompt for an active lineage child.
useEffect(() => {
if (activeLineageChildWorktreeId && activeLineageChildSshDisconnected) {
setLineageReconnectWorktreeId(activeLineageChildWorktreeId)
}
}, [activeLineageChildWorktreeId, activeLineageChildSshDisconnected])
const handleContainerKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
@ -762,6 +803,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="worktree-sidebar-scrollbar flex-1 overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
style={WORKTREE_SIDEBAR_SCROLL_STYLE}
>
{activeLineageChildConnectionId && activeLineageChildSshStatus ? (
<SshDisconnectedDialog
open={
lineageReconnectWorktreeId === activeLineageChildWorktreeId &&
activeLineageChildSshDisconnected
}
onOpenChange={(open) => {
if (!open) {
setLineageReconnectWorktreeId(null)
}
}}
targetId={activeLineageChildConnectionId}
targetLabel={
activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? ''
}
status={activeLineageChildSshStatus}
/>
) : null}
<div
role="presentation"
className="relative w-full"
@ -969,16 +1028,88 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
const renderLineageChildPreview = (child: WorktreeItemRow) => {
const renderWorktreeRow = (
itemRow: WorktreeItemRow,
nested: boolean,
lineageChildren?: React.ReactNode,
forceActiveSurface = false
) => {
const lineageToggleGroupKey = itemRow.lineageGroupKey
// Why: child cards render inside the parent card body, so their
// first nested level starts flush with that inset.
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
return (
<div
key={itemRow.worktree.id}
id={getWorktreeOptionId(itemRow.worktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(itemRow.worktree.id)}
aria-current={activeWorktreeId === itemRow.worktree.id ? 'page' : undefined}
className="relative"
// Why: nested child cards live inside the parent's clickable
// card body; bubbling would activate/edit the parent too.
onClick={nested ? stopNestedWorktreeCardBubble : undefined}
onDoubleClick={nested ? stopNestedWorktreeCardBubble : undefined}
onDragStart={nested ? stopNestedWorktreeCardBubble : undefined}
style={{
paddingLeft: paddingDepth > 0 ? `${paddingDepth * LINEAGE_INDENT}px` : undefined
}}
>
<WorktreeCard
worktree={itemRow.worktree}
repo={itemRow.repo}
isActive={activeWorktreeId === itemRow.worktree.id}
// Why: a child-active parent should look active without
// running active-card side effects such as SSH reconnect UI.
isActiveSurface={forceActiveSurface || activeWorktreeId === itemRow.worktree.id}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, itemRow.worktree)}
hideRepoBadge={groupBy === 'repo'}
parentLabel={
itemRow.depth > 0 && itemRow.lineageState === 'valid'
? undefined
: itemRow.parentLabel
}
lineageState={itemRow.lineageState}
lineageChildCount={itemRow.lineageChildCount}
lineageCollapsed={itemRow.lineageCollapsed}
lineageChildren={lineageChildren}
onLineageToggle={
lineageToggleGroupKey
? (event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}
: undefined
}
/>
</div>
)
}
const renderLineageChildCard = (child: WorktreeItemRow) => {
const isActive = activeWorktreeId === child.worktree.id
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
const selectionOnly = onSelectionGesture(event, child.worktree.id)
if (!selectionOnly) {
activateAndRevealWorktree(child.worktree.id)
if (selectionOnly) {
return
}
activateAndRevealWorktree(child.worktree.id)
if (child.repo?.connectionId) {
const sshStatus =
useAppStore.getState().sshConnectionStates.get(child.repo.connectionId)?.status ??
'disconnected'
if (sshStatus !== 'connected') {
setLineageReconnectWorktreeId(child.worktree.id)
}
}
}
const lineageToggleGroupKey = child.lineageGroupKey
return (
<div
key={child.worktree.id}
@ -1037,7 +1168,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{child.worktree.comment}
</div>
) : null}
{child.lineageChildCount > 0 && child.lineageGroupKey ? (
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
@ -1055,7 +1186,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroup(child.lineageGroupKey!)
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
@ -1080,7 +1211,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this compact
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
@ -1095,62 +1226,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
const renderWorktreeRow = (
itemRow: WorktreeItemRow,
nested: boolean,
lineageChildren?: React.ReactNode,
forceActiveSurface = false
) => {
const lineageToggleGroupKey = itemRow.lineageGroupKey
return (
<div
key={itemRow.worktree.id}
id={getWorktreeOptionId(itemRow.worktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(itemRow.worktree.id)}
aria-current={activeWorktreeId === itemRow.worktree.id ? 'page' : undefined}
className="relative"
style={{
paddingLeft:
nested && itemRow.depth > 0
? `${Math.max(0, itemRow.depth - 1) * LINEAGE_INDENT}px`
: itemRow.depth > 0
? `${itemRow.depth * LINEAGE_INDENT}px`
: undefined
}}
>
<WorktreeCard
worktree={itemRow.worktree}
repo={itemRow.repo}
isActive={forceActiveSurface || activeWorktreeId === itemRow.worktree.id}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, itemRow.worktree)}
hideRepoBadge={groupBy === 'repo'}
parentLabel={
itemRow.depth > 0 && itemRow.lineageState === 'valid'
? undefined
: itemRow.parentLabel
}
lineageState={itemRow.lineageState}
lineageChildCount={itemRow.lineageChildCount}
lineageCollapsed={itemRow.lineageCollapsed}
lineageChildren={lineageChildren}
onLineageToggle={
lineageToggleGroupKey
? (event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}
: undefined
}
/>
</div>
)
}
if (row.type === 'lineage-group') {
const [parent, ...children] = row.rows
const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId)
@ -1170,7 +1245,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
? renderWorktreeRow(
parent,
false,
children.length > 0 ? children.map(renderLineageChildPreview) : undefined,
children.length > 0
? children.map((child) => renderLineageChildCard(child))
: undefined,
childIsActive
)
: null}
@ -1814,7 +1891,6 @@ const WorktreeList = React.memo(function WorktreeList({
rows={rows}
activeWorktreeId={selectedSidebarWorktreeId}
groupBy={groupBy}
showInlineAgentCards={cardProps.includes('inline-agents')}
repoGroupOrdering={repoGroupOrdering}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
@ -1842,6 +1918,7 @@ const WorktreeList = React.memo(function WorktreeList({
onMoveWorktreesToStatus={moveWorktreesToStatus}
onPinWorktree={pinWorktree}
onPinWorktrees={pinWorktrees}
showInlineAgentCards={cardProps.includes('inline-agents')}
scrollOffsetRef={scrollOffsetRef}
scrollAnchorRef={scrollAnchorRef}
/>

View File

@ -23,7 +23,6 @@ async function seedLineageScenario(page: Page): Promise<LineageScenario> {
state.setSidebarOpen(true)
state.setGroupBy('none')
state.setSortBy('recent')
state.setShowWorkspaceLineage(true)
const worktrees = Object.values(state.worktreesByRepo)
.flat()