@@ -125,6 +125,27 @@ function makeReviewPanelCacheKey(params: {
125125}
126126
127127type ExecuteBashResult = Awaited < ReturnType < APIClient [ "workspace" ] [ "executeBash" ] > > ;
128+
129+ const ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS = 30_000 ;
130+
131+ function getOriginBranchForFetch ( diffBase : string ) : string | null {
132+ const trimmed = diffBase . trim ( ) ;
133+ if ( ! trimmed . startsWith ( "origin/" ) ) return null ;
134+
135+ const branch = trimmed . slice ( "origin/" . length ) ;
136+
137+ // Avoid shell injection; diffBase is user-controlled.
138+ if ( ! / ^ [ 0 - 9 A - Z a - z . _ / - ] + $ / . test ( branch ) ) return null ;
139+
140+ return branch ;
141+ }
142+
143+ function shouldWatchReviewDiffBase ( diffBase : string ) : boolean {
144+ // Base is considered local as long as it isn't a remote tracking ref.
145+ // Explicitly match the user requirement: skip when it starts with "origin/".
146+ return ! diffBase . trim ( ) . startsWith ( "origin/" ) ;
147+ }
148+
128149type ExecuteBashSuccess = Extract < ExecuteBashResult , { success : true } > ;
129150
130151async function executeWorkspaceBashAndCache < T extends ReviewPanelCacheValue > ( params : {
@@ -219,13 +240,174 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
219240 [ diffState ]
220241 ) ;
221242
243+ const [ originAutoRefreshSecondsRemaining , setOriginAutoRefreshSecondsRemaining ] = useState <
244+ number | null
245+ > ( null ) ;
246+ const originAutoRefreshDeadlineRef = useRef < number | null > ( null ) ;
247+ const originAutoRefreshInFlightRef = useRef ( false ) ;
222248 const [ filters , setFilters ] = useState < ReviewFiltersType > ( {
223249 showReadHunks : showReadHunks ,
224250 diffBase : diffBase ,
225251 includeUncommitted : includeUncommitted ,
226252 } ) ;
227253
254+ // Auto-refresh diffs for local bases by watching the filesystem.
255+ //
256+ // This is intentionally scoped:
257+ // - Only runs when the base is local (not starting with "origin/")
258+ // - Uses @parcel/watcher (native backends) for low-latency refresh
259+ useEffect ( ( ) => {
260+ if ( ! api || isCreating ) return ;
261+ if ( ! shouldWatchReviewDiffBase ( filters . diffBase ) ) return ;
262+
263+ const abortController = new AbortController ( ) ;
264+ const signal = abortController . signal ;
265+
266+ let debounceTimer : NodeJS . Timeout | null = null ;
267+
268+ ( async ( ) => {
269+ try {
270+ const iterator = await api . workspace . onFileChanges ( { workspaceId } , { signal } ) ;
271+ for await ( const _event of iterator ) {
272+ if ( signal . aborted ) break ;
273+
274+ if ( debounceTimer ) clearTimeout ( debounceTimer ) ;
275+ debounceTimer = setTimeout ( ( ) => {
276+ setRefreshTrigger ( ( prev ) => prev + 1 ) ;
277+ } , 75 ) ;
278+ }
279+ } catch {
280+ // Cancelled via abort signal (expected on cleanup)
281+ }
282+ } ) ( ) ;
283+
284+ return ( ) => {
285+ abortController . abort ( ) ;
286+ if ( debounceTimer ) clearTimeout ( debounceTimer ) ;
287+ } ;
288+ } , [ api , workspaceId , filters . diffBase , isCreating ] ) ;
289+
290+ // Auto-refresh remote origin/* bases every 30s (with a user-visible countdown).
291+ useEffect ( ( ) => {
292+ if ( ! api || isCreating ) {
293+ originAutoRefreshDeadlineRef . current = null ;
294+ originAutoRefreshInFlightRef . current = false ;
295+ setOriginAutoRefreshSecondsRemaining ( null ) ;
296+ return ;
297+ }
298+
299+ const originBranch = getOriginBranchForFetch ( filters . diffBase ) ;
300+ if ( ! originBranch ) {
301+ originAutoRefreshDeadlineRef . current = null ;
302+ originAutoRefreshInFlightRef . current = false ;
303+ setOriginAutoRefreshSecondsRemaining ( null ) ;
304+ return ;
305+ }
306+
307+ originAutoRefreshDeadlineRef . current = Date . now ( ) + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS ;
308+
309+ const resetCountdown = ( ) => {
310+ originAutoRefreshDeadlineRef . current = Date . now ( ) + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS ;
311+ setOriginAutoRefreshSecondsRemaining (
312+ Math . ceil ( ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000 )
313+ ) ;
314+ } ;
315+
316+ resetCountdown ( ) ;
317+
318+ let lastRenderedSeconds : number | null = null ;
319+
320+ const interval = setInterval ( ( ) => {
321+ const deadline = originAutoRefreshDeadlineRef . current ;
322+ if ( ! deadline ) return ;
323+
324+ const secondsRemaining = Math . max ( 0 , Math . ceil ( ( deadline - Date . now ( ) ) / 1000 ) ) ;
325+ if ( secondsRemaining !== lastRenderedSeconds ) {
326+ lastRenderedSeconds = secondsRemaining ;
327+ setOriginAutoRefreshSecondsRemaining ( secondsRemaining ) ;
328+ }
329+
330+ if ( secondsRemaining > 0 ) return ;
331+ if ( originAutoRefreshInFlightRef . current ) return ;
332+
333+ originAutoRefreshInFlightRef . current = true ;
334+
335+ // Reset early so we don't immediately re-fire if fetch takes time.
336+ resetCountdown ( ) ;
337+
338+ api . workspace
339+ . executeBash ( {
340+ workspaceId,
341+ script : `git fetch origin ${ originBranch } --quiet || true` ,
342+ options : {
343+ timeout_secs : 30 ,
344+ } ,
345+ } )
346+ . catch ( ( err ) => {
347+ console . debug ( "ReviewPanel origin fetch failed" , err ) ;
348+ } )
349+ . finally ( ( ) => {
350+ originAutoRefreshInFlightRef . current = false ;
351+ setRefreshTrigger ( ( prev ) => prev + 1 ) ;
352+ } ) ;
353+ } , 250 ) ;
354+
355+ return ( ) => {
356+ clearInterval ( interval ) ;
357+ originAutoRefreshDeadlineRef . current = null ;
358+ originAutoRefreshInFlightRef . current = false ;
359+ setOriginAutoRefreshSecondsRemaining ( null ) ;
360+ } ;
361+ } , [ api , workspaceId , filters . diffBase , isCreating ] ) ;
362+
228363 // Focus panel when focusTrigger changes (preserves current hunk selection)
364+
365+ const handleRefreshRef = useRef < ( ) => void > ( ( ) => {
366+ console . debug ( "ReviewPanel handleRefreshRef called before init" ) ;
367+ } ) ;
368+ handleRefreshRef . current = ( ) => {
369+ if ( ! api || isCreating ) return ;
370+
371+ const originBranch = getOriginBranchForFetch ( filters . diffBase ) ;
372+ if ( originBranch ) {
373+ // Reset countdown on manual refresh so the user doesn't see an immediate auto-refresh.
374+ originAutoRefreshDeadlineRef . current = Date . now ( ) + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS ;
375+ setOriginAutoRefreshSecondsRemaining (
376+ Math . ceil ( ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000 )
377+ ) ;
378+
379+ if ( originAutoRefreshInFlightRef . current ) {
380+ setRefreshTrigger ( ( prev ) => prev + 1 ) ;
381+ return ;
382+ }
383+
384+ originAutoRefreshInFlightRef . current = true ;
385+
386+ api . workspace
387+ . executeBash ( {
388+ workspaceId,
389+ script : `git fetch origin ${ originBranch } --quiet || true` ,
390+ options : {
391+ timeout_secs : 30 ,
392+ } ,
393+ } )
394+ . catch ( ( err ) => {
395+ console . debug ( "ReviewPanel origin fetch failed" , err ) ;
396+ } )
397+ . finally ( ( ) => {
398+ originAutoRefreshInFlightRef . current = false ;
399+ setRefreshTrigger ( ( prev ) => prev + 1 ) ;
400+ } ) ;
401+
402+ return ;
403+ }
404+
405+ setRefreshTrigger ( ( prev ) => prev + 1 ) ;
406+ } ;
407+
408+ const handleRefresh = ( ) => {
409+ handleRefreshRef . current ( ) ;
410+ } ;
229411 useEffect ( ( ) => {
230412 if ( focusTrigger && focusTrigger > 0 ) {
231413 panelRef . current ?. focus ( ) ;
@@ -730,7 +912,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
730912 const handleKeyDown = ( e : KeyboardEvent ) => {
731913 if ( matchesKeybind ( e , KEYBINDS . REFRESH_REVIEW ) ) {
732914 e . preventDefault ( ) ;
733- setRefreshTrigger ( ( prev ) => prev + 1 ) ;
915+ handleRefreshRef . current ( ) ;
734916 } else if ( matchesKeybind ( e , KEYBINDS . FOCUS_REVIEW_SEARCH ) ) {
735917 e . preventDefault ( ) ;
736918 searchInputRef . current ?. focus ( ) ;
@@ -765,7 +947,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
765947 filters = { filters }
766948 stats = { stats }
767949 onFiltersChange = { setFilters }
768- onRefresh = { ( ) => setRefreshTrigger ( ( prev ) => prev + 1 ) }
950+ onRefresh = { handleRefresh }
951+ autoRefreshSecondsRemaining = { originAutoRefreshSecondsRemaining }
769952 isLoading = {
770953 diffState . status === "loading" || diffState . status === "refreshing" || isLoadingTree
771954 }
0 commit comments