Improve child workspace card UI (#2213)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
956a6a2b03
commit
c78b8bde55
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue