some fixes

This commit is contained in:
Neil 2026-03-17 11:10:39 -07:00
parent c89ba34ec8
commit 93996ddb09
20 changed files with 536 additions and 167 deletions

View file

@ -27,6 +27,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tanstack/react-virtual": "^3.13.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.3.9",

View file

@ -23,6 +23,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@41.0.2)
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -1974,6 +1977,15 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/react-virtual@3.13.23':
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@ -6099,6 +6111,14 @@ snapshots:
tailwindcss: 4.2.1
vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.13.23
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/virtual-core@3.13.23': {}
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3

View file

@ -1,6 +1,9 @@
import { execFileSync } from 'child_process'
import { execFile, execFileSync } from 'child_process'
import { promisify } from 'util'
import type { GitWorktreeInfo } from '../../shared/types'
const execFileAsync = promisify(execFile)
/**
* Parse the porcelain output of `git worktree list --porcelain`.
*/
@ -40,14 +43,13 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
/**
* List all worktrees for a git repo at the given path.
*/
export function listWorktrees(repoPath: string): GitWorktreeInfo[] {
export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
try {
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
encoding: 'utf-8'
})
return parseWorktreeList(output)
return parseWorktreeList(stdout)
} catch {
return []
}

View file

@ -1,24 +1,51 @@
import { execFileSync } from 'child_process'
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRInfo, IssueInfo, CheckStatus } from '../../shared/types'
const execFileAsync = promisify(execFile)
// Concurrency limiter - max 4 parallel gh processes
const MAX_CONCURRENT = 4
let running = 0
const queue: Array<() => void> = []
function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
function release(): void {
running--
const next = queue.shift()
if (next) next()
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
*/
export function getPRForBranch(repoPath: string, branch: string): PRInfo | null {
export async function getPRForBranch(repoPath: string, branch: string): Promise<PRInfo | null> {
await acquire()
try {
// Strip refs/heads/ prefix if present
const branchName = branch.replace(/^refs\/heads\//, '')
const raw = execFileSync(
const { stdout } = await execFileAsync(
'gh',
['pr', 'view', branchName, '--json', 'number,title,state,url,statusCheckRollup,updatedAt'],
{
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
encoding: 'utf-8'
}
)
const data = JSON.parse(raw)
const data = JSON.parse(stdout)
return {
number: data.number,
title: data.title,
@ -29,24 +56,26 @@ export function getPRForBranch(repoPath: string, branch: string): PRInfo | null
}
} catch {
return null
} finally {
release()
}
}
/**
* Get a single issue by number.
*/
export function getIssue(repoPath: string, issueNumber: number): IssueInfo | null {
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
await acquire()
try {
const raw = execFileSync(
const { stdout } = await execFileAsync(
'gh',
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
{
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
encoding: 'utf-8'
}
)
const data = JSON.parse(raw)
const data = JSON.parse(stdout)
return {
number: data.number,
title: data.title,
@ -56,24 +85,26 @@ export function getIssue(repoPath: string, issueNumber: number): IssueInfo | nul
}
} catch {
return null
} finally {
release()
}
}
/**
* List issues for a repo.
*/
export function listIssues(repoPath: string, limit = 20): IssueInfo[] {
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
await acquire()
try {
const raw = execFileSync(
const { stdout } = await execFileAsync(
'gh',
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
{
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
encoding: 'utf-8'
}
)
const data = JSON.parse(raw) as Array<{
const data = JSON.parse(stdout) as Array<{
number: number
title: string
state: string
@ -89,6 +120,8 @@ export function listIssues(repoPath: string, limit = 20): IssueInfo[] {
}))
} catch {
return []
} finally {
release()
}
}

View file

@ -1,6 +1,6 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/types'
import type { GlobalSettings, PersistedState } from '../../shared/types'
export function registerSettingsHandlers(store: Store): void {
ipcMain.handle('settings:get', () => {
@ -10,4 +10,12 @@ export function registerSettingsHandlers(store: Store): void {
ipcMain.handle('settings:set', (_event, args: Partial<GlobalSettings>) => {
return store.updateSettings(args)
})
ipcMain.handle('cache:getGitHub', () => {
return store.getGitHubCache()
})
ipcMain.handle('cache:setGitHub', (_event, args: { cache: PersistedState['githubCache'] }) => {
store.setGitHubCache(args.cache)
})
}

View file

@ -7,12 +7,12 @@ import { getGitUsername, getDefaultBranch } from '../git/repo'
import { loadHooks, runHook, hasHooksFile } from '../hooks'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('worktrees:listAll', () => {
ipcMain.handle('worktrees:listAll', async () => {
const repos = store.getRepos()
const allWorktrees: Worktree[] = []
for (const repo of repos) {
const gitWorktrees = listWorktrees(repo.path)
const gitWorktrees = await listWorktrees(repo.path)
for (const gw of gitWorktrees) {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
@ -23,11 +23,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
return allWorktrees
})
ipcMain.handle('worktrees:list', (_event, args: { repoId: string }) => {
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return []
const gitWorktrees = listWorktrees(repo.path)
const gitWorktrees = await listWorktrees(repo.path)
return gitWorktrees.map((gw) => {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
@ -37,7 +37,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
ipcMain.handle(
'worktrees:create',
(_event, args: { repoId: string; name: string; baseBranch?: string }) => {
async (_event, args: { repoId: string; name: string; baseBranch?: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) throw new Error(`Repo not found: ${args.repoId}`)
@ -69,7 +69,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
addWorktree(repo.path, worktreePath, branchName, baseBranch)
// Re-list to get the freshly created worktree info
const gitWorktrees = listWorktrees(repo.path)
const gitWorktrees = await listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
if (!created) throw new Error('Worktree created but not found in listing')

View file

@ -132,6 +132,17 @@ export class Store {
this.scheduleSave()
}
// ── GitHub Cache ──────────────────────────────────────────────────
getGitHubCache(): PersistedState['githubCache'] {
return this.state.githubCache
}
setGitHubCache(cache: PersistedState['githubCache']): void {
this.state.githubCache = cache
this.scheduleSave()
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {

View file

@ -59,6 +59,19 @@ interface HooksApi {
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
}
interface CacheApi {
getGitHub: () => Promise<{
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}>
setGitHub: (args: {
cache: {
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}
}) => Promise<void>
}
interface Api {
repos: ReposApi
worktrees: WorktreesApi
@ -67,6 +80,7 @@ interface Api {
settings: SettingsApi
shell: ShellApi
hooks: HooksApi
cache: CacheApi
}
declare global {

View file

@ -103,6 +103,11 @@ const api = {
hooks: {
check: (args: { repoId: string }): Promise<{ hasHooks: boolean; hooks: unknown }> =>
ipcRenderer.invoke('hooks:check', args)
},
cache: {
getGitHub: () => ipcRenderer.invoke('cache:getGitHub'),
setGitHub: (args: { cache: unknown }) => ipcRenderer.invoke('cache:setGitHub', args)
}
}

View file

@ -12,17 +12,19 @@ function App(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
// Subscribe to IPC push events
useIpcEvents()
const settings = useAppStore((s) => s.settings)
// Fetch initial data
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
fetchRepos()
fetchSettings()
}, [fetchRepos, fetchSettings])
initGitHubCache()
}, [fetchRepos, fetchSettings, initGitHubCache])
// Apply theme to document
useEffect(() => {

View file

@ -145,6 +145,37 @@
}
}
/* ── Sleek scrollbar (hover-only, thin) ─────────────── */
.scrollbar-sleek {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-sleek::-webkit-scrollbar {
width: 6px;
}
.scrollbar-sleek::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-sleek::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
.scrollbar-sleek-parent:hover .scrollbar-sleek::-webkit-scrollbar-thumb,
.scrollbar-sleek:hover::-webkit-scrollbar-thumb {
background: var(--muted-foreground, #737373);
opacity: 0.4;
}
.scrollbar-sleek-parent:hover .scrollbar-sleek,
.scrollbar-sleek:hover {
scrollbar-color: var(--muted-foreground, #737373) transparent;
}
/* ── Layout ──────────────────────────────────────────── */
#root {

View file

@ -250,6 +250,52 @@ export default function TerminalPane({
wasActiveRef.current = isActive
}, [isActive])
// Terminal pane shortcuts handled at window capture phase so they remain
// reliable even when focus is inside the canvas/IME internals.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (!isActive || e.repeat) return
if (!e.metaKey || e.altKey || e.ctrlKey) return
const restty = resttyRef.current
if (!restty) return
// Cmd+K clears active pane screen + scrollback.
if (!e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
e.stopPropagation()
const activePane = restty.activePane?.()
if (activePane) {
activePane.clearScreen()
return
}
const pane = restty.getActivePane() ?? restty.getPanes()[0]
pane?.app.clearScreen()
return
}
// Cmd+[ / Cmd+] cycles active split pane focus.
if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
const panes = restty.getPanes()
if (panes.length < 2) return
e.preventDefault()
e.stopPropagation()
const activeId = restty.getActivePane()?.id ?? panes[0].id
const currentIdx = panes.findIndex((p) => p.id === activeId)
if (currentIdx === -1) return
const dir = e.code === 'BracketRight' ? 1 : -1
const nextPane = panes[(currentIdx + dir + panes.length) % panes.length]
restty.setActivePane(nextPane.id, { focus: true })
return
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isActive])
return (
<div
ref={containerRef}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useSyncExternalStore } from 'react'
import { cn } from '@/lib/utils'
const BRAILLE_FRAMES = [
@ -15,7 +15,47 @@ const BRAILLE_FRAMES = [
]
const FRAME_INTERVAL = 80
type Status = 'active' | 'working' | 'inactive'
// ── Shared global spinner ────────────────────────────────────────
// A single setInterval drives ALL spinner instances. Components
// subscribe via useSyncExternalStore — zero per-instance timers.
let _frame = 0
let _subscribers = 0
let _interval: ReturnType<typeof setInterval> | null = null
const _listeners = new Set<() => void>()
function startSharedTimer(): void {
if (_interval !== null) return
_interval = setInterval(() => {
_frame = (_frame + 1) % BRAILLE_FRAMES.length
for (const cb of _listeners) cb()
}, FRAME_INTERVAL)
}
function stopSharedTimer(): void {
if (_interval === null) return
clearInterval(_interval)
_interval = null
_frame = 0
}
function subscribeFrame(cb: () => void): () => void {
_listeners.add(cb)
_subscribers++
if (_subscribers === 1) startSharedTimer()
return () => {
_listeners.delete(cb)
_subscribers--
if (_subscribers === 0) stopSharedTimer()
}
}
function getFrame(): number {
return _frame
}
// ─────────────────────────────────────────────────────────────────
type Status = 'active' | 'working' | 'permission' | 'inactive'
interface StatusIndicatorProps {
status: Status
@ -26,27 +66,18 @@ const StatusIndicator = React.memo(function StatusIndicator({
status,
className
}: StatusIndicatorProps) {
const [frame, setFrame] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (status === 'working') {
intervalRef.current = setInterval(() => {
setFrame((f) => (f + 1) % BRAILLE_FRAMES.length)
}, FRAME_INTERVAL)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}
setFrame(0)
return undefined
}, [status])
// Only subscribes to the shared timer when status === 'working'.
// When not working, the subscribe is a no-op (returns identity unsub).
const frame = useSyncExternalStore(
status === 'working' ? subscribeFrame : noopSubscribe,
getFrame
)
if (status === 'working') {
return (
<span
className={cn(
'text-[11px] leading-none text-foreground font-mono w-3 text-center shrink-0',
'inline-flex h-3 w-3 items-center justify-center shrink-0 text-[11px] leading-none text-foreground font-mono',
className
)}
>
@ -56,15 +87,24 @@ const StatusIndicator = React.memo(function StatusIndicator({
}
return (
<span
className={cn(
'block size-2 rounded-full shrink-0',
status === 'active' ? 'bg-emerald-500' : 'bg-neutral-500/40',
className
)}
/>
<span className={cn('inline-flex h-3 w-3 items-center justify-center shrink-0', className)}>
<span
className={cn(
'block size-2 rounded-full',
status === 'active'
? 'bg-emerald-500'
: status === 'permission'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>
)
})
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noopUnsubscribe = (): void => {}
const noopSubscribe = (): (() => void) => noopUnsubscribe
export default StatusIndicator
export type { Status }

View file

@ -1,17 +1,19 @@
import React, { useEffect, useMemo, useRef } from 'react'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
import { cn } from '@/lib/utils'
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
import type {
Worktree,
Repo,
PRInfo,
IssueInfo,
PRState,
CheckStatus
CheckStatus,
TerminalTab
} from '../../../../shared/types'
import type { Status } from './StatusIndicator'
@ -42,6 +44,9 @@ function checksLabel(status: CheckStatus): string {
}
}
// ── Stable empty array for tabs fallback ─────────────────────────
const EMPTY_TABS: TerminalTab[] = []
interface WorktreeCardProps {
worktree: Worktree
repo: Repo | undefined
@ -54,29 +59,39 @@ const WorktreeCard = React.memo(function WorktreeCard({
isActive
}: WorktreeCardProps) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const tabs = tabsByWorktree[worktree.id] ?? []
const hasTerminals = tabs.length > 0
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
const branch = branchDisplayName(worktree.branch)
const prCacheKey = repo ? `${repo.path}::${branch}` : ''
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
// Subscribe to ONLY the specific cache entry, not entire prCache/issueCache
const prEntry = useAppStore((s) => (prCacheKey ? s.prCache[prCacheKey] : undefined))
const issueEntry = useAppStore((s) => (issueCacheKey ? s.issueCache[issueCacheKey] : undefined))
const pr: PRInfo | null | undefined = prEntry !== undefined ? prEntry.data : undefined
const issue: IssueInfo | null | undefined = worktree.linkedIssue
? issueEntry !== undefined
? issueEntry.data
: undefined
: null
const hasTerminals = tabs.length > 0
// Derive status
const status: Status = useMemo(() => {
if (!hasTerminals) return 'inactive'
// Simple heuristic: if any tab has a pty, it's active
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')) return 'permission'
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')) return 'working'
return tabs.some((t) => t.ptyId) ? 'active' : 'inactive'
}, [hasTerminals, tabs])
// Fetch PR data
const prCacheKey = repo ? `${repo.path}::${branch}` : ''
const prEntry = prCacheKey ? prCache[prCacheKey] : undefined
const pr: PRInfo | null | undefined = prEntry !== undefined ? prEntry.data : undefined
// Fetch PR data (debounced via ref guard)
const prFetchedRef = useRef<string | null>(null)
useEffect(() => {
if (
repo &&
@ -90,30 +105,33 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
}, [repo, worktree.isBare, pr, fetchPRForBranch, branch, prCacheKey])
// Fetch issue data
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
const issueEntry = issueCacheKey ? issueCache[issueCacheKey] : undefined
const issue: IssueInfo | null | undefined = worktree.linkedIssue
? issueEntry !== undefined
? issueEntry.data
: undefined
: null
// Fetch issue data (debounced via ref guard)
const issueFetchedRef = useRef<string | null>(null)
useEffect(() => {
const issueKey = worktree.linkedIssue ?? null
if (
repo &&
issueKey &&
worktree.linkedIssue &&
issue === undefined &&
issueCacheKey &&
issueCacheKey !== issueFetchedRef.current
) {
issueFetchedRef.current = issueCacheKey
fetchIssue(repo.path, issueKey)
fetchIssue(repo.path, worktree.linkedIssue)
}
}, [repo, worktree.linkedIssue, issue, fetchIssue, issueCacheKey])
// Stable click handler
const handleClick = useCallback(
() => setActiveWorktree(worktree.id),
[worktree.id, setActiveWorktree]
)
// Memoize badge style to avoid new object each render
const badgeStyle = useMemo(
() => (repo ? { backgroundColor: repo.badgeColor + '22', color: repo.badgeColor } : undefined),
[repo?.badgeColor]
)
return (
<WorktreeContextMenu worktree={worktree}>
<div
@ -121,7 +139,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
'group relative flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer transition-colors',
isActive ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => setActiveWorktree(worktree.id)}
onClick={handleClick}
>
{/* Status + unread indicator */}
<div className="flex items-center pt-1 gap-1">
@ -144,7 +162,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
<Badge
variant="secondary"
className="h-4 px-1.5 text-[9px] font-medium rounded-sm shrink-0"
style={{ backgroundColor: repo.badgeColor + '22', color: repo.badgeColor }}
style={badgeStyle}
>
{repo.displayName}
</Badge>

View file

@ -1,4 +1,5 @@
import React, { useMemo } from 'react'
import React, { useMemo, useCallback, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import WorktreeCard from './WorktreeCard'
import type { Worktree, Repo } from '../../../../shared/types'
@ -7,7 +8,13 @@ function branchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
// ── Row types for the virtualizer ───────────────────────────────
type GroupHeaderRow = { type: 'header'; label: string; count: number }
type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
type Row = GroupHeaderRow | WorktreeRow
const WorktreeList = React.memo(function WorktreeList() {
// ── Granular selectors (each is a primitive or shallow-stable ref) ──
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@ -16,8 +23,14 @@ const WorktreeList = React.memo(function WorktreeList() {
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const filterRepoId = useAppStore((s) => s.filterRepoId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const prCache = useAppStore((s) => s.prCache)
// Only read tabsByWorktree when showActiveOnly is on (avoid subscription otherwise)
const tabsByWorktree = useAppStore((s) => (showActiveOnly ? s.tabsByWorktree : null))
// PR cache only when grouping by pr-status
const prCache = useAppStore((s) => (groupBy === 'pr-status' ? s.prCache : null))
const scrollRef = useRef<HTMLDivElement>(null)
const repoMap = useMemo(() => {
const m = new Map<string, Repo>()
@ -49,7 +62,7 @@ const WorktreeList = React.memo(function WorktreeList() {
}
// Filter active only
if (showActiveOnly) {
if (showActiveOnly && tabsByWorktree) {
all = all.filter((w) => {
const tabs = tabsByWorktree[w.id] ?? []
return tabs.some((t) => t.ptyId)
@ -77,43 +90,10 @@ const WorktreeList = React.memo(function WorktreeList() {
return all
}, [worktreesByRepo, filterRepoId, searchQuery, showActiveOnly, sortBy, repoMap, tabsByWorktree])
// Group
const groups = useMemo(() => {
if (groupBy === 'none') {
return [{ label: null, items: worktrees }]
}
// Collapsed group state
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
if (groupBy === 'repo') {
const map = new Map<string, Worktree[]>()
for (const w of worktrees) {
const label = repoMap.get(w.repoId)?.displayName ?? 'Unknown'
if (!map.has(label)) map.set(label, [])
map.get(label)!.push(w)
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }))
}
if (groupBy === 'pr-status') {
const buckets = new Map<string, Worktree[]>()
for (const w of worktrees) {
const repo = repoMap.get(w.repoId)
const branch = branchName(w.branch)
const cacheKey = repo ? `${repo.path}::${branch}` : ''
const prEntry = cacheKey ? prCache[cacheKey] : undefined
const pr = prEntry !== undefined ? prEntry.data : undefined
const label = pr ? pr.state.charAt(0).toUpperCase() + pr.state.slice(1) : 'No PR'
if (!buckets.has(label)) buckets.set(label, [])
buckets.get(label)!.push(w)
}
return Array.from(buckets.entries()).map(([label, items]) => ({ label, items }))
}
return [{ label: null, items: worktrees }]
}, [groupBy, worktrees, repoMap, prCache])
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
const toggleGroup = React.useCallback((label: string) => {
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(label)) next.delete(label)
@ -122,6 +102,61 @@ const WorktreeList = React.memo(function WorktreeList() {
})
}, [])
// Build flat row list for virtualizer
const rows: Row[] = useMemo(() => {
const result: Row[] = []
if (groupBy === 'none') {
for (const w of worktrees) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
return result
}
// Group items
const grouped = new Map<string, Worktree[]>()
for (const w of worktrees) {
let label: string
if (groupBy === 'repo') {
label = repoMap.get(w.repoId)?.displayName ?? 'Unknown'
} else {
// pr-status
const repo = repoMap.get(w.repoId)
const branch = branchName(w.branch)
const cacheKey = repo ? `${repo.path}::${branch}` : ''
const prEntry = cacheKey && prCache ? prCache[cacheKey] : undefined
const pr = prEntry !== undefined ? prEntry.data : undefined
label = pr ? pr.state.charAt(0).toUpperCase() + pr.state.slice(1) : 'No PR'
}
if (!grouped.has(label)) grouped.set(label, [])
grouped.get(label)!.push(w)
}
for (const [label, items] of grouped) {
const isCollapsed = collapsedGroups.has(label)
result.push({ type: 'header', label, count: items.length })
if (!isCollapsed) {
for (const w of items) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
}
}
return result
}, [groupBy, worktrees, repoMap, prCache, collapsedGroups])
// ── TanStack Virtual ──────────────────────────────────────────
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => (rows[index].type === 'header' ? 28 : 56),
overscan: 10,
getItemKey: (index) => {
const row = rows[index]
return row.type === 'header' ? `hdr:${row.label}` : `wt:${row.worktree.id}`
}
})
if (worktrees.length === 0) {
return (
<div className="px-4 py-6 text-center text-[11px] text-muted-foreground">
@ -131,42 +166,56 @@ const WorktreeList = React.memo(function WorktreeList() {
}
return (
<div className="px-1 space-y-0.5">
{groups.map((group) => {
const key = group.label ?? '__all__'
const isCollapsed = group.label ? collapsedGroups.has(group.label) : false
<div ref={scrollRef} className="flex-1 overflow-auto px-1 scrollbar-sleek">
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((vItem) => {
const row = rows[vItem.index]
return (
<div key={key}>
{group.label && (
<button
className="flex items-center gap-1 px-2 pt-2 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground w-full text-left hover:text-foreground transition-colors"
onClick={() => toggleGroup(group.label!)}
if (row.type === 'header') {
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<span
className="inline-block transition-transform text-[8px]"
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
<button
className="flex items-center gap-1 px-2 pt-2 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground w-full text-left hover:text-foreground transition-colors"
onClick={() => toggleGroup(row.label)}
>
&#9660;
</span>
{group.label}
<span className="ml-auto text-[9px] font-normal tabular-nums">
{group.items.length}
</span>
</button>
)}
{!isCollapsed &&
group.items.map((wt) => (
<WorktreeCard
key={wt.id}
worktree={wt}
repo={repoMap.get(wt.repoId)}
isActive={activeWorktreeId === wt.id}
/>
))}
</div>
)
})}
<span
className="inline-block transition-transform text-[8px]"
style={{
transform: collapsedGroups.has(row.label) ? 'rotate(-90deg)' : 'rotate(0deg)'
}}
>
&#9660;
</span>
{row.label}
<span className="ml-auto text-[9px] font-normal tabular-nums">{row.count}</span>
</button>
</div>
)
}
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<WorktreeCard
worktree={row.worktree}
repo={row.repo}
isActive={activeWorktreeId === row.worktree.id}
/>
</div>
)
})}
</div>
</div>
)
})

View file

@ -1,6 +1,5 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { useAppStore } from '@/store'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipProvider } from '@/components/ui/tooltip'
import SidebarHeader from './SidebarHeader'
import SearchBar from './SearchBar'
@ -72,19 +71,19 @@ export default function Sidebar(): React.JSX.Element {
return (
<TooltipProvider delayDuration={400}>
<div
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200"
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200 scrollbar-sleek-parent"
style={{
width: sidebarOpen ? sidebarWidth : 0,
borderRight: sidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{/* Scrollable area: header + search + controls + list */}
<ScrollArea className="flex-1 min-h-0">
<SidebarHeader />
<SearchBar />
<GroupControls />
<WorktreeList />
</ScrollArea>
{/* Fixed controls */}
<SidebarHeader />
<SearchBar />
<GroupControls />
{/* Virtualized scrollable list */}
<WorktreeList />
{/* Fixed bottom toolbar */}
<SidebarToolbar />

View file

@ -0,0 +1,52 @@
export type AgentStatus = 'working' | 'permission' | 'idle'
const GEMINI_WORKING = '\u2726' // ✦
const GEMINI_IDLE = '\u25C7' // ◇
const GEMINI_PERMISSION = '\u270B' // ✋
const AGENT_NAMES = ['claude', 'codex', 'gemini', 'opencode', 'aider']
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
function containsAgentName(title: string): boolean {
const lower = title.toLowerCase()
return AGENT_NAMES.some((name) => lower.includes(name))
}
function containsAny(title: string, words: string[]): boolean {
const lower = title.toLowerCase()
return words.some((word) => lower.includes(word))
}
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title) return null
// Gemini CLI symbols are the most specific and should take precedence.
if (title.includes(GEMINI_PERMISSION)) return 'permission'
if (title.includes(GEMINI_WORKING)) return 'working'
if (title.includes(GEMINI_IDLE)) return 'idle'
if (containsBrailleSpinner(title)) return 'working'
if (containsAgentName(title)) {
if (containsAny(title, ['action required', 'permission', 'waiting'])) return 'permission'
if (containsAny(title, ['ready', 'idle', 'done'])) return 'idle'
if (containsAny(title, ['working', 'thinking', 'running'])) return 'working'
// Claude Code title prefixes: ". " = working, "* " = idle
if (title.startsWith('. ')) return 'working'
if (title.startsWith('* ')) return 'idle'
return 'idle'
}
return null
}

View file

@ -7,23 +7,52 @@ export interface CacheEntry<T> {
fetchedAt: number
}
const CACHE_TTL = 60_000 // 60 seconds
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSaveCache(state: AppState): void {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = null
window.api.cache.setGitHub({
cache: {
pr: state.prCache,
issue: state.issueCache
}
})
}, 1000) // Save at most once per second
}
export interface GitHubSlice {
prCache: Record<string, CacheEntry<PRInfo>>
issueCache: Record<string, CacheEntry<IssueInfo>>
fetchPRForBranch: (repoPath: string, branch: string) => Promise<PRInfo | null>
fetchIssue: (repoPath: string, number: number) => Promise<IssueInfo | null>
initGitHubCache: () => Promise<void>
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
prCache: {},
issueCache: {},
initGitHubCache: async () => {
try {
const persisted = await window.api.cache.getGitHub()
if (persisted) {
set({
prCache: persisted.pr || {},
issueCache: persisted.issue || {}
})
}
} catch (err) {
console.error('Failed to load GitHub cache from disk:', err)
}
},
fetchPRForBranch: async (repoPath, branch) => {
const cacheKey = `${repoPath}::${branch}`
const cached = get().prCache[cacheKey]
@ -34,12 +63,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
set((s) => ({
prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } }
}))
debouncedSaveCache(get())
return pr
} catch (err) {
console.error('Failed to fetch PR:', err)
set((s) => ({
prCache: { ...s.prCache, [cacheKey]: { data: null, fetchedAt: Date.now() } }
}))
debouncedSaveCache(get())
return null
}
},
@ -54,12 +85,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
set((s) => ({
issueCache: { ...s.issueCache, [cacheKey]: { data: issue, fetchedAt: Date.now() } }
}))
debouncedSaveCache(get())
return issue
} catch (err) {
console.error('Failed to fetch issue:', err)
set((s) => ({
issueCache: { ...s.issueCache, [cacheKey]: { data: null, fetchedAt: Date.now() } }
}))
debouncedSaveCache(get())
return null
}
}

View file

@ -35,6 +35,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
sidebarWidth: 280
}
},
githubCache: { pr: {}, issue: {} }
}
}

View file

@ -101,4 +101,8 @@ export interface PersistedState {
lastActiveWorktreeId: string | null
sidebarWidth: number
}
githubCache: {
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}
}