feat: configurable worktree cards with property toggles (#232)

- fix: stay under max-lines lint limit after merge with main

Extract restoreScrollbackBuffers into layout-serialization.ts to reduce
use-terminal-pane-lifecycle.ts below the 300-line limit. Also filter
stale leaf IDs from persisted scrollback buffers and remove design doc.

- fix: polish worktree card controls

- feat: configurable worktree cards with property toggles
This commit is contained in:
Jinjing
2026-03-31 08:49:00 -07:00
committed by GitHub
parent f2eb119c62
commit e0e01cd687
11 changed files with 218 additions and 139 deletions
+4 -1
View File
@@ -1,4 +1,6 @@
import { useEffect } from 'react'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
@@ -100,7 +102,8 @@ function App(): React.JSX.Element {
groupBy: 'none',
sortBy: 'name',
filterRepoIds: [],
uiZoomLevel: 0
uiZoomLevel: 0,
worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES]
})
hydrateWorkspaceSession({
activeRepoId: null,
@@ -1,41 +1,13 @@
import React from 'react'
import { ArrowUpAZ, ArrowUpDown, Check, Clock3, FolderTree } from 'lucide-react'
import { useAppStore } from '@/store'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const SORT_OPTIONS = {
name: {
label: 'Name',
icon: ArrowUpAZ
},
recent: {
label: 'Recent',
icon: Clock3
},
repo: {
label: 'Repo',
icon: FolderTree
}
} as const
const GroupControls = React.memo(function GroupControls() {
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
const selectedSort = SORT_OPTIONS[sortBy]
const SelectedSortIcon = selectedSort.icon
return (
<div className="flex items-center justify-between px-2 pb-1.5">
<div className="flex items-center justify-between px-2 pb-1.5 gap-1">
<ToggleGroup
type="single"
value={groupBy}
@@ -46,7 +18,7 @@ const GroupControls = React.memo(function GroupControls() {
}}
variant="outline"
size="sm"
className="h-6"
className="h-6 flex-1 justify-start"
>
<ToggleGroupItem
value="none"
@@ -67,44 +39,6 @@ const GroupControls = React.memo(function GroupControls() {
Repo
</ToggleGroupItem>
</ToggleGroup>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="w-auto gap-1 px-1.5 text-muted-foreground"
aria-label={`Sort by ${selectedSort.label}`}
>
<ArrowUpDown className="size-3" />
<SelectedSortIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Sort by {selectedSort.label}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-0">
{Object.entries(SORT_OPTIONS).map(([value, option]) => {
const Icon = option.icon
const isSelected = value === sortBy
return (
<DropdownMenuItem
key={value}
onSelect={() => setSortBy(value as typeof sortBy)}
className="pr-7"
>
<Icon className="size-3.5" />
<span>{option.label}</span>
{isSelected ? <Check className="ml-auto size-3.5 text-foreground" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
@@ -1,40 +1,130 @@
import React from 'react'
import { Plus } from 'lucide-react'
import { Plus, SlidersHorizontal } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuRadioGroup,
DropdownMenuRadioItem
} from '@/components/ui/dropdown-menu'
import type { WorktreeCardProperty } from '../../../../shared/types'
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'status', label: 'Terminal status' },
{ id: 'unread', label: 'Unread indicator' },
{ id: 'ci', label: 'CI checks' },
{ id: 'issue', label: 'Linked issue' },
{ id: 'pr', label: 'Linked PR' },
{ id: 'comment', label: 'Comment' }
]
const SORT_OPTIONS = [
{ id: 'name', label: 'Name' },
{ id: 'recent', label: 'Recent' },
{ id: 'repo', label: 'Repo' }
] as const
const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
const SidebarHeader = React.memo(function SidebarHeader() {
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.length > 0
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
return (
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
Worktrees
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorktree) {
return
}
openModal('create-worktree')
}}
aria-label="Add worktree"
disabled={!canCreateWorktree}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorktree ? 'New worktree (⌘N)' : 'Add a repo to create worktrees'}
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1.5 shrink-0">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="View options"
>
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
View options
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-56 pb-2">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(v) => setSortBy(v as typeof sortBy)}
>
{SORT_OPTIONS.map((opt) => (
<DropdownMenuRadioItem
key={opt.id}
value={opt.id}
// Keep the menu open so people can compare sort modes and
// toggle card properties without reopening the same panel.
onSelect={(e) => e.preventDefault()}
>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Show properties</DropdownMenuLabel>
{PROPERTY_OPTIONS.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.id}
checked={worktreeCardProperties.includes(opt.id)}
onCheckedChange={() => toggleWorktreeCardProperty(opt.id)}
onSelect={(e) => e.preventDefault()}
>
{opt.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorktree) {
return
}
openModal('create-worktree')
}}
aria-label="Add worktree"
disabled={!canCreateWorktree}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorktree
? `New worktree (${newWorktreeShortcutLabel})`
: 'Add a repo to create worktrees'}
</TooltipContent>
</Tooltip>
</div>
</div>
)
})
@@ -99,6 +99,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -166,18 +167,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
return liveTabs.length > 0 ? 'active' : 'inactive'
}, [hasTerminals, tabs])
// Fetch PR data on mount. The store handles freshness checks, and
// activity-based refresh is triggered by setActiveWorktree + visibilitychange.
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
const showIssue = cardProps.includes('issue')
// Skip GitHub fetches when the corresponding card sections are hidden.
// This preference is purely presentational, so background refreshes would
// spend rate limit budget on data the user cannot see.
useEffect(() => {
if (repo && !worktree.isBare && prCacheKey) {
if (repo && !worktree.isBare && prCacheKey && (showPR || showCI)) {
fetchPRForBranch(repo.path, branch)
}
}, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey])
}, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey, showPR, showCI])
// Fetch issue data on mount + background poll as safety net.
// Primary refresh comes from setActiveWorktree + visibilitychange.
// Same rationale for issues: once that section is hidden, polling only burns
// GitHub calls and keeps stale-but-invisible data warm for no user benefit.
useEffect(() => {
if (!repo || !worktree.linkedIssue || !issueCacheKey) {
if (!repo || !worktree.linkedIssue || !issueCacheKey || !showIssue) {
return
}
@@ -189,7 +195,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}, 5 * 60_000) // 5 minutes
return () => clearInterval(interval)
}, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey])
}, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
// Stable click handler ignore clicks that are really text selections
const handleClick = useCallback(() => {
@@ -244,33 +250,37 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{/* Status indicator on the left */}
<div className="flex flex-col items-center justify-start pt-[2px] gap-2 shrink-0">
<StatusIndicator status={status} />
{(cardProps.includes('status') || cardProps.includes('unread')) && (
<div className="flex flex-col items-center justify-start pt-[2px] gap-2 shrink-0">
{cardProps.includes('status') && <StatusIndicator status={status} />}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleUnreadQuick}
className={cn(
'group/unread flex size-4 cursor-pointer items-center justify-center rounded transition-all',
'hover:bg-accent/80 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
)}
aria-label={worktree.isUnread ? 'Mark as read' : 'Mark as unread'}
>
{worktree.isUnread ? (
<FilledBellIcon className="size-[13px] text-amber-500 drop-shadow-sm" />
) : (
<Bell className="size-3 text-muted-foreground/40 opacity-0 group-hover:opacity-100 group-hover/unread:opacity-100 transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{unreadTooltip}</span>
</TooltipContent>
</Tooltip>
</div>
{cardProps.includes('unread') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleUnreadQuick}
className={cn(
'group/unread flex size-4 cursor-pointer items-center justify-center rounded transition-all',
'hover:bg-accent/80 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
)}
aria-label={worktree.isUnread ? 'Mark as read' : 'Mark as unread'}
>
{worktree.isUnread ? (
<FilledBellIcon className="size-[13px] text-amber-500 drop-shadow-sm" />
) : (
<Bell className="size-3 text-muted-foreground/40 opacity-0 group-hover:opacity-100 group-hover/unread:opacity-100 transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{unreadTooltip}</span>
</TooltipContent>
</Tooltip>
)}
</div>
)}
{/* Content area */}
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
@@ -281,8 +291,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
{/* CI Checks & PR state on the right */}
<div className="flex items-center gap-2 shrink-0">
{pr && pr.checksStatus !== 'neutral' && (
{cardProps.includes('ci') && pr && pr.checksStatus !== 'neutral' && (
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center opacity-80 hover:opacity-100 transition-opacity">
@@ -301,8 +311,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
<span>CI checks {checksLabel(pr.checksStatus).toLowerCase()}</span>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
{/* Subtitle row: Repo badge + Branch */}
@@ -348,9 +358,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
{/* Meta section: Issue / PR Links / Comment */}
{(issue || worktree.comment || pr) && (
{((cardProps.includes('issue') && issue) ||
(cardProps.includes('pr') && pr) ||
(cardProps.includes('comment') && worktree.comment)) && (
<div className="flex flex-col gap-[3px] mt-0.5">
{issue && (
{cardProps.includes('issue') && issue && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div
@@ -400,7 +412,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
</HoverCard>
)}
{pr && (
{cardProps.includes('pr') && pr && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div
@@ -462,7 +474,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
</HoverCard>
)}
{worktree.comment && (
{cardProps.includes('comment') && worktree.comment && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div
@@ -309,7 +309,13 @@ const WorktreeList = React.memo(function WorktreeList() {
}
return (
<div ref={scrollRef} className="flex-1 overflow-auto px-1 pt-px scrollbar-sleek scroll-smooth">
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-auto px-1 scrollbar-sleek scroll-smooth',
groupBy === 'none' ? 'pt-2' : 'pt-px'
)}
>
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((vItem) => {
const row = rows[vItem.index]
@@ -28,7 +28,7 @@ export default function Sidebar(): React.JSX.Element {
}
}, [repoCount, fetchAllWorktrees])
// ─── Resize logic ────────────────────────────────────
// ─── Resize logic ───────────────────────────────────────────────────
const isResizing = useRef(false)
const startX = useRef(0)
const startWidth = useRef(0)
@@ -86,7 +86,10 @@ export default function TerminalPane({
// reorder) don't clobber previously captured scrollback.
const existing = useAppStore.getState().terminalLayoutsByTabId[tabId]
if (existing?.buffersByLeafId) {
layout.buffersByLeafId = existing.buffersByLeafId
const currentLeafIds = new Set(manager.getPanes().map((p) => paneLeafId(p.id)))
layout.buffersByLeafId = Object.fromEntries(
Object.entries(existing.buffersByLeafId).filter(([id]) => currentLeafIds.has(id))
)
}
setTabLayout(tabId, layout)
}
@@ -138,6 +138,8 @@ export function restoreScrollbackBuffers(
}
try {
let buf = buffer
// If buffer ends in alt-screen mode (agent TUI was running at
// shutdown), exit alt-screen so the user sees a usable terminal.
const lastOn = buf.lastIndexOf(ALT_SCREEN_ON)
const lastOff = buf.lastIndexOf(ALT_SCREEN_OFF)
if (lastOn > lastOff) {
+16 -1
View File
@@ -1,6 +1,7 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { PersistedUIState, UpdateStatus } from '../../../../shared/types'
import type { PersistedUIState, UpdateStatus, WorktreeCardProperty } from '../../../../shared/types'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants'
type LegacyPersistedSortBy = PersistedUIState['sortBy'] | 'smart'
@@ -26,6 +27,8 @@ export type UISlice = {
setShowActiveOnly: (v: boolean) => void
filterRepoIds: string[]
setFilterRepoIds: (ids: string[]) => void
worktreeCardProperties: WorktreeCardProperty[]
toggleWorktreeCardProperty: (prop: WorktreeCardProperty) => void
pendingRevealWorktreeId: string | null
revealWorktreeInSidebar: (worktreeId: string) => void
clearPendingRevealWorktreeId: () => void
@@ -67,6 +70,17 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
filterRepoIds: [],
setFilterRepoIds: (ids) => set({ filterRepoIds: ids }),
worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES],
toggleWorktreeCardProperty: (prop) =>
set((s) => {
const current = s.worktreeCardProperties || DEFAULT_WORKTREE_CARD_PROPERTIES
const updated = current.includes(prop)
? current.filter((p) => p !== prop)
: [...current, prop]
window.api.ui.set({ worktreeCardProperties: updated }).catch(console.error)
return { worktreeCardProperties: updated }
}),
pendingRevealWorktreeId: null,
revealWorktreeInSidebar: (worktreeId) => set({ pendingRevealWorktreeId: worktreeId }),
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktreeId: null }),
@@ -82,6 +96,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
groupBy: ui.groupBy,
sortBy,
filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)),
worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES],
persistedUIReady: true
}
}),
+13 -2
View File
@@ -3,12 +3,22 @@ import type {
PersistedState,
PersistedUIState,
RepoHookSettings,
WorkspaceSessionState
WorkspaceSessionState,
WorktreeCardProperty
} from './types'
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
export const SCHEMA_VERSION = 1
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'status',
'unread',
'ci',
'issue',
'pr',
'comment'
]
export const REPO_COLORS = [
'#737373', // neutral
'#ef4444', // red
@@ -77,7 +87,8 @@ export function getDefaultUIState(): PersistedUIState {
groupBy: 'none',
sortBy: 'name',
filterRepoIds: [],
uiZoomLevel: 0
uiZoomLevel: 0,
worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES]
}
}
+3
View File
@@ -194,6 +194,8 @@ export type GlobalSettings = {
rightSidebarOpenByDefault: boolean
}
export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment'
export type PersistedUIState = {
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
@@ -203,6 +205,7 @@ export type PersistedUIState = {
sortBy: 'name' | 'recent' | 'repo'
filterRepoIds: string[]
uiZoomLevel: number
worktreeCardProperties: WorktreeCardProperty[]
}
// ─── Persistence shape ──────────────────────────────────────────────