feat: implement real-time file tree refresh on agent tool completion (#19)

- Add fileChangeStore for tracking file changes via Zustand
- Detect file-modifying tools (write, edit, notebookedit, bash) and trigger refresh
- Implement periodic refresh every 2s while agent is processing
- Add hidden file filtering to FileTree component
- Subscribe to refresh events in FileTree to update directory contents

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan
2026-01-15 12:45:20 +08:00
committed by GitHub
parent 8b3c2004f0
commit da6c25de39
3 changed files with 140 additions and 3 deletions

View File

@@ -2,7 +2,7 @@
* FileTree component for displaying directory structure
* With lazy loading and context menu support
*/
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
ChevronRightIcon,
FolderIcon,
@@ -28,6 +28,7 @@ import {
GitIcon,
} from './FileIcons'
import type { FileTreeNode, DetectedApp } from '../../../shared/electron-api'
import { useFileChangeStore } from '../stores/fileChangeStore'
interface FileTreeProps {
rootPath: string
@@ -277,6 +278,9 @@ function TreeItem({
)
}
// Filter out hidden files (starting with .)
const filterHidden = (nodes: FileTreeNode[]) => nodes.filter((n) => !n.name.startsWith('.'))
export function FileTree({ rootPath }: FileTreeProps) {
const [rootChildren, setRootChildren] = useState<FileTreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
@@ -284,6 +288,10 @@ export function FileTree({ rootPath }: FileTreeProps) {
const [availableApps, setAvailableApps] = useState<DetectedApp[]>([])
const [isLoading, setIsLoading] = useState(true)
// Subscribe to file change events for auto-refresh
const refreshCounter = useFileChangeStore((s) => s.refreshCounter)
const isInitialMount = useRef(true)
// Load root directory and detect apps on mount
useEffect(() => {
async function init() {
@@ -293,7 +301,7 @@ export function FileTree({ rootPath }: FileTreeProps) {
window.electronAPI.listDirectory(rootPath),
window.electronAPI.detectApps(),
])
setRootChildren(children)
setRootChildren(filterHidden(children))
setAvailableApps(apps)
} catch (error) {
console.error('Failed to load file tree:', error)
@@ -303,6 +311,59 @@ export function FileTree({ rootPath }: FileTreeProps) {
init()
}, [rootPath])
// Keep a ref to expandedPaths for use in refresh effect without triggering it
const expandedPathsRef = useRef(expandedPaths)
expandedPathsRef.current = expandedPaths
// Refresh file tree when files change (from agent tool calls)
useEffect(() => {
// Skip on initial mount (already loaded above)
if (isInitialMount.current) {
isInitialMount.current = false
return
}
console.log('[FileTree] Refresh triggered, counter:', refreshCounter)
// Refresh: clear cache and re-fetch root + expanded directories
async function refresh() {
try {
// Re-fetch root directory
console.log('[FileTree] Fetching root directory:', rootPath)
const children = await window.electronAPI.listDirectory(rootPath)
const filtered = filterHidden(children)
console.log('[FileTree] Root directory result:', filtered.length, 'items', filtered.map(c => c.name))
setRootChildren(filtered)
// Re-fetch all expanded directories to update them
const currentExpanded = Array.from(expandedPathsRef.current)
if (currentExpanded.length > 0) {
console.log('[FileTree] Refreshing expanded dirs:', currentExpanded)
const newCache = new Map<string, FileTreeNode[]>()
const results = await Promise.all(
currentExpanded.map(async (path) => {
try {
const dirChildren = await window.electronAPI.listDirectory(path)
return { path, children: filterHidden(dirChildren) }
} catch {
// Directory may no longer exist
return { path, children: [] }
}
})
)
for (const { path, children: dirChildren } of results) {
newCache.set(path, dirChildren)
}
setChildrenCache(newCache)
}
} catch (error) {
console.error('[FileTree] Failed to refresh file tree:', error)
}
}
refresh()
}, [refreshCounter, rootPath])
const handleToggle = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
@@ -318,10 +379,11 @@ export function FileTree({ rootPath }: FileTreeProps) {
const loadChildren = useCallback(async (path: string) => {
// Fetch children first, then check cache in the setter to avoid stale closure
const children = await window.electronAPI.listDirectory(path)
const filtered = filterHidden(children)
setChildrenCache((prev) => {
// Skip if already cached (handles race conditions)
if (prev.has(path)) return prev
return new Map(prev).set(path, children)
return new Map(prev).set(path, filtered)
})
}, [])

View File

@@ -8,6 +8,7 @@ import type {
} from '../../../shared/types'
import type { RunningSessionsStatus } from '../../../shared/electron-api'
import { usePermissionStore } from '../stores/permissionStore'
import { useFileChangeStore } from '../stores/fileChangeStore'
export interface AppState {
// Sessions
@@ -56,6 +57,25 @@ export function useApp(): AppState & AppActions {
? runningSessionsStatus.processingSessionIds.includes(currentSession.id)
: false
// Periodic file tree refresh while agent is processing
useEffect(() => {
if (!isProcessing) return
const triggerRefresh = useFileChangeStore.getState().triggerRefresh
const REFRESH_INTERVAL = 2000 // Refresh every 2 seconds while processing
console.log('[FileChange] Starting periodic refresh (agent processing)')
const intervalId = setInterval(() => {
console.log('[FileChange] Periodic refresh triggered')
triggerRefresh()
}, REFRESH_INTERVAL)
return () => {
console.log('[FileChange] Stopping periodic refresh')
clearInterval(intervalId)
}
}, [isProcessing])
// Load sessions on mount
useEffect(() => {
loadSessions()
@@ -86,6 +106,12 @@ export function useApp(): AppState & AppActions {
// Subscribe to agent events
useEffect(() => {
// Get triggerRefresh from store for file change detection
const triggerRefresh = useFileChangeStore.getState().triggerRefresh
// Tool kinds that modify files (case-insensitive)
const FILE_MODIFYING_TOOLS = new Set(['write', 'edit', 'notebookedit', 'bash'])
const unsubMessage = window.electronAPI.onAgentMessage((message) => {
// Only process messages for the current session
// message.sessionId is ACP Agent Session ID, compare with currentAgentSessionId
@@ -93,6 +119,34 @@ export function useApp(): AppState & AppActions {
return
}
// Check for file-modifying tool completion to trigger FileTree refresh
const update = message.update
const kind = update?.kind?.toLowerCase() || ''
const status = update?.status?.toLowerCase() || ''
// Debug logging for tool updates
if (update?.sessionUpdate === 'tool_call_update' || update?.sessionUpdate === 'tool_call') {
console.log('[FileChange] Tool event:', {
sessionUpdate: update.sessionUpdate,
kind,
status,
title: update?.title,
rawUpdate: update
})
}
// Trigger refresh when a file-modifying tool completes
// More lenient: trigger on any status that isn't "running" or "pending" or "in_progress"
const isCompleted = status && !['running', 'pending', 'in_progress', ''].includes(status)
if (
update?.sessionUpdate === 'tool_call_update' &&
FILE_MODIFYING_TOOLS.has(kind) &&
isCompleted
) {
console.log('[FileChange] Triggering refresh for:', { kind, status })
triggerRefresh()
}
// Pass through original update without any accumulation
// ChatView is responsible for accumulating chunks into complete messages
setSessionUpdates((prev) => {

View File

@@ -0,0 +1,21 @@
/**
* File change state management
* Tracks file changes from agent tool calls to trigger FileTree refresh
*/
import { create } from 'zustand'
interface FileChangeStore {
// Counter that increments when files change - used to trigger refresh
refreshCounter: number
// Trigger a file refresh
triggerRefresh: () => void
}
export const useFileChangeStore = create<FileChangeStore>((set) => ({
refreshCounter: 0,
triggerRefresh: () => {
set((state) => ({ refreshCounter: state.refreshCounter + 1 }))
},
}))