Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function App(): React.JSX.Element {
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
const openModal = useAppStore((s) => s.openModal)
Expand Down Expand Up @@ -167,6 +168,17 @@ function App(): React.JSX.Element {
}
}, [settings?.theme])

// Refresh GitHub data (PR/issue status) when window regains focus
useEffect(() => {
const handler = (): void => {
if (document.visibilityState === 'visible') {
refreshAllGitHub()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [refreshAllGitHub])

const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const hasTabBar = tabs.length >= 2
const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null
Expand Down
41 changes: 24 additions & 17 deletions src/renderer/src/components/sidebar/WorktreeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import React, { useEffect, useMemo, useCallback } from 'react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
Expand Down Expand Up @@ -50,7 +50,7 @@ function checksLabel(status: CheckStatus): string {
// ── Stable empty array for tabs fallback ─────────────────────────
const EMPTY_TABS: TerminalTab[] = []

interface WorktreeCardProps {
type WorktreeCardProps = {
worktree: Worktree
repo: Repo | undefined
isActive: boolean
Expand Down Expand Up @@ -103,7 +103,9 @@ const WorktreeCard = React.memo(function WorktreeCard({

// Derive status
const status: Status = useMemo(() => {
if (!hasTerminals) return 'inactive'
if (!hasTerminals) {
return 'inactive'
}
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
return 'permission'
Expand All @@ -114,32 +116,37 @@ const WorktreeCard = React.memo(function WorktreeCard({
return liveTabs.length > 0 ? 'active' : 'inactive'
}, [hasTerminals, tabs])

// Fetch PR data on mount – the store's isFresh() check prevents redundant API calls
// Fetch PR data on mount. The store handles freshness checks, and
// activity-based refresh is triggered by setActiveWorktree + visibilitychange.
useEffect(() => {
if (repo && !worktree.isBare && prCacheKey) {
fetchPRForBranch(repo.path, branch)
}
}, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey])

// Fetch issue data (debounced via ref guard)
const issueFetchedRef = useRef<string | null>(null)
// Fetch issue data on mount + background poll as safety net.
// Primary refresh comes from setActiveWorktree + visibilitychange.
useEffect(() => {
if (
repo &&
worktree.linkedIssue &&
issue === undefined &&
issueCacheKey &&
issueCacheKey !== issueFetchedRef.current
) {
issueFetchedRef.current = issueCacheKey
fetchIssue(repo.path, worktree.linkedIssue)
if (!repo || !worktree.linkedIssue || !issueCacheKey) {
return
}
}, [repo, worktree.linkedIssue, issue, fetchIssue, issueCacheKey])

fetchIssue(repo.path, worktree.linkedIssue)

// Background poll as fallback (activity triggers handle the fast path)
const interval = setInterval(() => {
fetchIssue(repo.path, worktree.linkedIssue!)
}, 5 * 60_000) // 5 minutes

return () => clearInterval(interval)
}, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey])

// Stable click handler – ignore clicks that are really text selections
const handleClick = useCallback(() => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) return
if (selection && selection.toString().length > 0) {
return
}
setActiveWorktree(worktree.id)
}, [worktree.id, setActiveWorktree])

Expand Down
111 changes: 103 additions & 8 deletions src/renderer/src/store/slices/github.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { PRInfo, IssueInfo } from '../../../../shared/types'
import type { PRInfo, IssueInfo, Worktree } from '../../../../shared/types'

export interface CacheEntry<T> {
export type CacheEntry<T> = {
data: T | null
fetchedAt: number
}
Expand All @@ -17,8 +17,11 @@ function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
}

let saveTimer: ReturnType<typeof setTimeout> | null = null

function debouncedSaveCache(state: AppState): void {
if (saveTimer) clearTimeout(saveTimer)
if (saveTimer) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => {
saveTimer = null
window.api.cache.setGitHub({
Expand All @@ -30,12 +33,14 @@ function debouncedSaveCache(state: AppState): void {
}, 1000) // Save at most once per second
}

export interface GitHubSlice {
export type 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>
refreshAllGitHub: () => void
refreshGitHubForWorktree: (worktreeId: string) => void
}

export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
Expand All @@ -59,9 +64,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
fetchPRForBranch: async (repoPath, branch) => {
const cacheKey = `${repoPath}::${branch}`
const cached = get().prCache[cacheKey]
if (isFresh(cached)) return cached.data
if (isFresh(cached)) {
return cached.data
}

const inflightRequest = inflightPRRequests.get(cacheKey)
if (inflightRequest) return inflightRequest
if (inflightRequest) {
return inflightRequest
}

const request = (async () => {
try {
Expand Down Expand Up @@ -90,9 +100,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
fetchIssue: async (repoPath, number) => {
const cacheKey = `${repoPath}::${number}`
const cached = get().issueCache[cacheKey]
if (isFresh(cached)) return cached.data
if (isFresh(cached)) {
return cached.data
}

const inflightRequest = inflightIssueRequests.get(cacheKey)
if (inflightRequest) return inflightRequest
if (inflightRequest) {
return inflightRequest
}

const request = (async () => {
try {
Expand All @@ -116,5 +131,85 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s

inflightIssueRequests.set(cacheKey, request)
return request
},

refreshAllGitHub: () => {
// Invalidate all cache entries so next fetch bypasses TTL
set((s) => {
const nextPr: Record<string, CacheEntry<PRInfo>> = {}
for (const [k, v] of Object.entries(s.prCache)) {
nextPr[k] = { ...v, fetchedAt: 0 }
}
const nextIssue: Record<string, CacheEntry<IssueInfo>> = {}
for (const [k, v] of Object.entries(s.issueCache)) {
nextIssue[k] = { ...v, fetchedAt: 0 }
}
return { prCache: nextPr, issueCache: nextIssue }
})

// Re-fetch all worktrees' PR + issue data
const state = get()
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const wt of worktrees) {
const repo = state.repos.find((r) => r.id === wt.repoId)
if (!repo) {
continue
}

const branch = wt.branch.replace(/^refs\/heads\//, '')
if (!wt.isBare) {
void get().fetchPRForBranch(repo.path, branch)
}
if (wt.linkedIssue) {
void get().fetchIssue(repo.path, wt.linkedIssue)
}
}
}
},

refreshGitHubForWorktree: (worktreeId) => {
const state = get()
let worktree: Worktree | undefined
for (const worktrees of Object.values(state.worktreesByRepo)) {
worktree = worktrees.find((w) => w.id === worktreeId)
if (worktree) {
break
}
}
if (!worktree) {
return
}

const repo = state.repos.find((r) => r.id === worktree.repoId)
if (!repo) {
return
}

// Invalidate this worktree's cache entries
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const prKey = `${repo.path}::${branch}`
const issueKey = worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''

set((s) => {
const updates: Partial<AppState> = {}
if (s.prCache[prKey]) {
updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } }
}
if (issueKey && s.issueCache[issueKey]) {
updates.issueCache = {
...s.issueCache,
[issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 }
}
}
return updates
})

// Re-fetch
if (!worktree.isBare) {
void get().fetchPRForBranch(repo.path, branch)
}
if (worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue)
}
}
})
41 changes: 31 additions & 10 deletions src/renderer/src/store/slices/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, WorktreeMeta } from '../../../../shared/types'

export interface WorktreeDeleteState {
export type WorktreeDeleteState = {
isDeleting: boolean
error: string | null
canForceDelete: boolean
}

export interface WorktreeSlice {
export type WorktreeSlice = {
worktreesByRepo: Record<string, Worktree[]>
activeWorktreeId: string | null
deleteStateByWorktreeId: Record<string, WorktreeDeleteState>
Expand Down Expand Up @@ -126,7 +126,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>

clearWorktreeDeleteState: (worktreeId) => {
set((s) => {
if (!s.deleteStateByWorktreeId[worktreeId]) return {}
if (!s.deleteStateByWorktreeId[worktreeId]) {
return {}
}
const next = { ...s.deleteStateByWorktreeId }
delete next[worktreeId]
return { deleteStateByWorktreeId: next }
Expand All @@ -149,19 +151,25 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>

markWorktreeUnreadFromBell: (worktreeId) => {
const activeWorktreeId = get().activeWorktreeId
if (activeWorktreeId === worktreeId) return
if (activeWorktreeId === worktreeId) {
return
}

let shouldPersist = false
set((s) => {
const worktree = findWorktreeById(s.worktreesByRepo, worktreeId)
if (!worktree || worktree.isUnread) return {}
if (!worktree || worktree.isUnread) {
return {}
}
shouldPersist = true
return {
worktreesByRepo: applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { isUnread: true })
}
})

if (!shouldPersist) return
if (!shouldPersist) {
return
}

void window.api.worktrees
.updateMeta({ worktreeId, updates: { isUnread: true } })
Expand All @@ -174,7 +182,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
setActiveWorktree: (worktreeId) => {
let shouldClearUnread = false
set((s) => {
if (!worktreeId) return { activeWorktreeId: null }
if (!worktreeId) {
return { activeWorktreeId: null }
}

const worktree = findWorktreeById(s.worktreesByRepo, worktreeId)
shouldClearUnread = Boolean(worktree?.isUnread)
Expand All @@ -186,7 +196,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})

if (!worktreeId || !shouldClearUnread) return
// Refresh GitHub data (PR + issue status) for the activated worktree
if (worktreeId) {
get().refreshGitHubForWorktree(worktreeId)
}

if (!worktreeId || !shouldClearUnread) {
return
}

void window.api.worktrees
.updateMeta({ worktreeId, updates: { isUnread: false } })
Expand All @@ -205,7 +222,9 @@ function findWorktreeById(
): Worktree | undefined {
for (const worktrees of Object.values(worktreesByRepo)) {
const match = worktrees.find((worktree) => worktree.id === worktreeId)
if (match) return match
if (match) {
return match
}
}

return undefined
Expand All @@ -222,7 +241,9 @@ function applyWorktreeUpdates(
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
let repoChanged = false
const nextWorktrees = worktrees.map((worktree) => {
if (worktree.id !== worktreeId) return worktree
if (worktree.id !== worktreeId) {
return worktree
}

const updatedWorktree = { ...worktree, ...updates }
repoChanged = true
Expand Down