From 93996ddb09d77ac045eb1e90e47a53be1e41def2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:10:39 -0700 Subject: [PATCH] some fixes --- package.json | 1 + pnpm-lock.yaml | 20 ++ src/main/git/worktree.ts | 14 +- src/main/github/client.ts | 65 ++++-- src/main/ipc/settings.ts | 10 +- src/main/ipc/worktrees.ts | 12 +- src/main/persistence.ts | 11 + src/preload/index.d.ts | 14 ++ src/preload/index.ts | 5 + src/renderer/src/App.tsx | 6 +- src/renderer/src/assets/main.css | 31 +++ src/renderer/src/components/TerminalPane.tsx | 46 +++++ .../components/sidebar/StatusIndicator.tsx | 90 +++++--- .../src/components/sidebar/WorktreeCard.tsx | 72 ++++--- .../src/components/sidebar/WorktreeList.tsx | 195 +++++++++++------- src/renderer/src/components/sidebar/index.tsx | 17 +- src/renderer/src/lib/agent-status.ts | 52 +++++ src/renderer/src/store/slices/github.ts | 35 +++- src/shared/constants.ts | 3 +- src/shared/types.ts | 4 + 20 files changed, 536 insertions(+), 167 deletions(-) create mode 100644 src/renderer/src/lib/agent-status.ts diff --git a/package.json b/package.json index 3ff1d2db1..d43d0b034 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abd5a2345..f9ec94af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ce98fc338..c3ca873b5 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -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 { 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 [] } diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ab5e8579a..c204fe400 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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() } } diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index ab4dddd0b..59a50eb55 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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) => { return store.updateSettings(args) }) + + ipcMain.handle('cache:getGitHub', () => { + return store.getGitHubCache() + }) + + ipcMain.handle('cache:setGitHub', (_event, args: { cache: PersistedState['githubCache'] }) => { + store.setGitHubCache(args.cache) + }) } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 1ba1d56e0..605e5f52e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index df25995a7..834c0de54 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6c9fc96d5..2fdeeb583 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -59,6 +59,19 @@ interface HooksApi { check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }> } +interface CacheApi { + getGitHub: () => Promise<{ + pr: Record + issue: Record + }> + setGitHub: (args: { + cache: { + pr: Record + issue: Record + } + }) => Promise +} + interface Api { repos: ReposApi worktrees: WorktreesApi @@ -67,6 +80,7 @@ interface Api { settings: SettingsApi shell: ShellApi hooks: HooksApi + cache: CacheApi } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 98f73905b..bc4c71220 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 81a4ba174..fd854eb18 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(() => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 117a871b1..c5af5caa4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 { diff --git a/src/renderer/src/components/TerminalPane.tsx b/src/renderer/src/components/TerminalPane.tsx index 2f358a16b..3e0e575f3 100644 --- a/src/renderer/src/components/TerminalPane.tsx +++ b/src/renderer/src/components/TerminalPane.tsx @@ -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 (
| 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 | 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 ( @@ -56,15 +87,24 @@ const StatusIndicator = React.memo(function StatusIndicator({ } return ( - + + + ) }) +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noopUnsubscribe = (): void => {} +const noopSubscribe = (): (() => void) => noopUnsubscribe + export default StatusIndicator export type { Status } diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index f8debb7f5..63e87b78e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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(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(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 (
setActiveWorktree(worktree.id)} + onClick={handleClick} > {/* Status + unread indicator */}
@@ -144,7 +162,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ {repo.displayName} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e95365064..e14326db3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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(null) const repoMap = useMemo(() => { const m = new Map() @@ -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>(new Set()) - if (groupBy === 'repo') { - const map = new Map() - 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() - 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>(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() + 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 (
@@ -131,42 +166,56 @@ const WorktreeList = React.memo(function WorktreeList() { } return ( -
- {groups.map((group) => { - const key = group.label ?? '__all__' - const isCollapsed = group.label ? collapsedGroups.has(group.label) : false +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const row = rows[vItem.index] - return ( -
- {group.label && ( - - )} - {!isCollapsed && - group.items.map((wt) => ( - - ))} -
- ) - })} + + ▼ + + {row.label} + {row.count} + +
+ ) + } + + return ( +
+ +
+ ) + })} +
) }) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index c47c16823..1d4c66a9a 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -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 (
- {/* Scrollable area: header + search + controls + list */} - - - - - - + {/* Fixed controls */} + + + + + {/* Virtualized scrollable list */} + {/* Fixed bottom toolbar */} diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts new file mode 100644 index 000000000..0de597262 --- /dev/null +++ b/src/renderer/src/lib/agent-status.ts @@ -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 +} diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 8e22b54e9..57bc62b92 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -7,23 +7,52 @@ export interface CacheEntry { fetchedAt: number } -const CACHE_TTL = 60_000 // 60 seconds +const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed) function isFresh(entry: CacheEntry | undefined): entry is CacheEntry { return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL } +let saveTimer: ReturnType | 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> issueCache: Record> fetchPRForBranch: (repoPath: string, branch: string) => Promise fetchIssue: (repoPath: string, number: number) => Promise + initGitHubCache: () => Promise } export const createGitHubSlice: StateCreator = (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 = (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 = (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 } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 40d8e6631..0bce49ed3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -35,6 +35,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { lastActiveRepoId: null, lastActiveWorktreeId: null, sidebarWidth: 280 - } + }, + githubCache: { pr: {}, issue: {} } } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 5032aaf42..26e102088 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -101,4 +101,8 @@ export interface PersistedState { lastActiveWorktreeId: string | null sidebarWidth: number } + githubCache: { + pr: Record + issue: Record + } }