mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
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:
@@ -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)
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
21
src/renderer/src/stores/fileChangeStore.ts
Normal file
21
src/renderer/src/stores/fileChangeStore.ts
Normal 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 }))
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user