From da6c25de39810fa3d9b8bb073697fe4927c796d7 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Thu, 15 Jan 2026 12:45:20 +0800 Subject: [PATCH] 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 --- src/renderer/src/components/FileTree.tsx | 68 +++++++++++++++++++++- src/renderer/src/hooks/useApp.ts | 54 +++++++++++++++++ src/renderer/src/stores/fileChangeStore.ts | 21 +++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/stores/fileChangeStore.ts diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index 7e3ec96fed..fda9b950e9 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -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([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) @@ -284,6 +288,10 @@ export function FileTree({ rootPath }: FileTreeProps) { const [availableApps, setAvailableApps] = useState([]) 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() + 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) }) }, []) diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 1a810a32da..5087be4f00 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -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) => { diff --git a/src/renderer/src/stores/fileChangeStore.ts b/src/renderer/src/stores/fileChangeStore.ts new file mode 100644 index 0000000000..160c41911c --- /dev/null +++ b/src/renderer/src/stores/fileChangeStore.ts @@ -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((set) => ({ + refreshCounter: 0, + + triggerRefresh: () => { + set((state) => ({ refreshCounter: state.refreshCounter + 1 })) + }, +}))