Skip to content

Commit 4ad8aa3

Browse files
agegrclaude
andauthored
fix(files): reload the file tree while a run writes and on window focus (#1144) (#1153)
The tree reloaded only when a run ended or on the refresh button, so files the agent wrote mid-run, or changed in an editor or terminal, stayed hidden. Now any top-level tool but read/grep/find/ls schedules a reload (at most one a second), and the explorer reloads when the window gets focus. No watcher, no polling. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e7d0fda commit 4ad8aa3

5 files changed

Lines changed: 38 additions & 5 deletions

File tree

‎components/AppShell.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import type { ChatInputHandle } from "./ChatInput";
7171
import type { AgentEndInfo, NewSessionChoices } from "@/hooks/useAgentSession";
7272
import type { SessionStatsInfo } from "@/lib/pi-types";
7373
import type { FileViewerState } from "@/lib/file-viewer-state";
74-
import type { ToolEntry } from "@/lib/tool-presets";
74+
import { PRESET_READ_ONLY, type ToolEntry } from "@/lib/tool-presets";
7575
import { getSessionFamily } from "@/lib/session-family";
7676
import { getLastSettingsSection, settingsSectionRequiresProject, type SettingsSection } from "@/lib/settings-navigation";
7777

@@ -84,6 +84,9 @@ type AutoNameStatus =
8484

8585
const TOP_BAR_ICON_BUTTON_SIZE = 36;
8686
const AGENT_PANEL_WIDTH = 420;
87+
// pi's built-in tools that never change a file; any other tool may (#1144).
88+
const READ_ONLY_TOOL_NAMES = new Set(PRESET_READ_ONLY);
89+
const TOOL_END_REFRESH_MS = 1000;
8790

8891
function parkedNewSessionDraftKey(cwd: string): string {
8992
return `parked-new:${cwd}`;
@@ -1076,6 +1079,20 @@ export function AppShell() {
10761079
setExplorerRefreshKey((k) => k + 1);
10771080
}, []);
10781081

1082+
// A run's writes show in the file tree as they happen, not only when it ends
1083+
// (#1144): at most one refresh a second, after any tool but the read-only ones.
1084+
const toolEndRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1085+
const handleToolEnd = useCallback((toolName: string) => {
1086+
if (READ_ONLY_TOOL_NAMES.has(toolName) || toolEndRefreshTimerRef.current) return;
1087+
toolEndRefreshTimerRef.current = setTimeout(() => {
1088+
toolEndRefreshTimerRef.current = null;
1089+
setExplorerRefreshKey((k) => k + 1);
1090+
}, TOOL_END_REFRESH_MS);
1091+
}, []);
1092+
useEffect(() => () => {
1093+
if (toolEndRefreshTimerRef.current) clearTimeout(toolEndRefreshTimerRef.current);
1094+
}, []);
1095+
10791096
const handleSessionForked = useCallback((newSessionId: string) => {
10801097
invalidateWorkspaceRestore();
10811098
activeNewSessionDraftKeyRef.current = null;
@@ -2519,6 +2536,7 @@ export function AppShell() {
25192536
initialNewSessionChoices={selectedSession === null ? carriedNewSessionChoices : null}
25202537
onNewSessionChoicesChange={handleNewSessionChoicesChange}
25212538
onAgentEnd={handleAgentEnd}
2539+
onToolEnd={handleToolEnd}
25222540
onAttentionNeeded={handleAttentionNeeded}
25232541
onSessionCreated={handleSessionCreated}
25242542
onSessionForked={handleSessionForked}

‎components/ChatWindow.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface Props {
5555
initialNewSessionChoices?: NewSessionChoices | null;
5656
onNewSessionChoicesChange?: (choices: NewSessionChoices) => void;
5757
onAgentEnd?: (end: AgentEndInfo) => void;
58+
onToolEnd?: (toolName: string) => void;
5859
onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void;
5960
onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => void;
6061
onSessionForked?: (newSessionId: string) => void;
@@ -247,7 +248,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, defaultExpanded = fa
247248
);
248249
}
249250

250-
export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initialScrollPosition, onScrollPositionChange, sessionRunning, newSessionCwd, newSessionDraftKey, newSessionContextBar, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onOpenSettings, onContextUsageChange, onOpenFile, onFilesUploaded, onOpenSubagent, onAskInNewChat, quoteSelectionEnabled = false, initialPrompt, onInitialPromptConsumed, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio }: Props) {
251+
export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initialScrollPosition, onScrollPositionChange, sessionRunning, newSessionCwd, newSessionDraftKey, newSessionContextBar, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onOpenSettings, onContextUsageChange, onOpenFile, onFilesUploaded, onOpenSubagent, onAskInNewChat, quoteSelectionEnabled = false, initialPrompt, onInitialPromptConsumed, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio }: Props) {
251252
const { t } = useI18n();
252253
const isMobile = useIsMobile();
253254
const completionNotificationsEnabled = session?.relation?.kind !== "subagent";
@@ -301,7 +302,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
301302
handleToolPresetChange, handleThinkingLevelChange, handleSetDefaultModel, handleSetDefaultThinkingLevel, loadSlashCommands, scrollUserMsgToTop,
302303
loadContext, activeLeafId, scrollToBottom, scrollToMessage,
303304
} = useAgentSession({
304-
session, sessionRunning, newSessionCwd, newSessionDraftKey, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
305+
session, sessionRunning, newSessionCwd, newSessionDraftKey, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd: wrappedOnAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
305306
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen,
306307
onOpenSettings,
307308
deferInitialScroll: Boolean(pendingScrollRestore),

‎components/FileExplorer.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,14 @@ export const FileExplorer = forwardRef<FileExplorerHandle, Props>(function FileE
946946
return () => { cancelled = true; };
947947
}, [cwd, refreshKey, treeRefreshKey, showHidden]);
948948

949+
// Files changed outside pi-web (an editor, a terminal) show up once the
950+
// window is back in front (#1144).
951+
useEffect(() => {
952+
const refresh = () => setTreeRefreshKey((key) => key + 1);
953+
window.addEventListener("focus", refresh);
954+
return () => window.removeEventListener("focus", refresh);
955+
}, []);
956+
949957
useEffect(() => {
950958
let cancelled = false;
951959
fetchGitStatus(cwd)

‎docs/agents/files-and-access.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
- The files tab head's eye switch, "Show ignored files" (a toggle with `aria-pressed`, off by default, kept per browser in `lib/sidebar-prefs.ts` and restored after hydration like the tab), lists with `type=list&hidden=1`: the Git-ignored and name-list entries come back with `hidden: "ignored" | "excluded"` (`getFileTreeHiddenReasons()`), and the tree dims them with the reason in the name's tooltip. `.git` and `.DS_Store` stay out either way; dotfiles get no rule of their own. Inside a hidden directory Git has no answer (nothing below it is tracked), so its entries inherit the parent's reason on the client. Toggling reloads open directories and marks collapsed ones for reload. Search and `@` mentions do not follow the switch.
1818
- A directory link (symlink or Windows junction) is authorized by where it resolves (#748): one inside a root that leads outside every root is listed but refused beneath it. Never authorize the lexical path: a link committed to a cloned repo would expose `~/.ssh` or `/`. The listing reports its target as `outsideLinkTarget`; the explorer's "Allow browsing" posts `?type=allow-link` with it. `checkLinkedDirectoryApproval()` (`lib/linked-directory.ts`) requires the link's directory to be inside the roots after resolving links and the link to still point where the operator was shown; the route then `allowFileRoot()`s the target until the server restarts — the grant `/api/cwd/validate` gives any directory, so it widens nothing a caller could not already reach. A target that contains a root or the home folder (`/`, `~`, a parent of the project) is listed with `outsideLinkEncloses`, and the explorer confirms before allowing it.
1919

20+
## When the file tree reloads
21+
- No watcher and no polling: the tree (root, every open folder, git status) reloads when a run ends, at most once a second while a run's tools finish (any top-level tool but the read-only preset's `read`/`grep`/`find`/`ls`: AppShell's `handleToolEnd`, from `useAgentSession`'s `onToolEnd`), when the window gets focus (changes made in an editor or terminal), after an upload, and on the refresh button (#1144). A reload keeps the open folders and shows no spinner.
22+
2023
## Uploads and chat drops
2124
- The explorer's Upload button and a non-image file dropped onto the chat share one client, `uploadFiles()` (`lib/file-upload-client.ts`), and the one endpoint, `POST /api/files/<cwd>?type=upload`; never add a second write path for drops. The explorer asks `upload-check` first and lets the user replace or skip; a chat drop goes straight to `conflict=skip`, so it never replaces a file. It then inserts `@name` for every file the response lists as uploaded *or* skipped (a skipped name is a file already there, often the very file dragged out of the project) and says in a notice that the mention points to the existing file.
2225
- Dropped images still attach to the prompt. Folders are left out with a notice: the endpoint writes flat file names only (`validateUploadFileNames()`), and the browser cannot tell where a dropped file lives on disk, so the copy always lands in the cwd root.

‎hooks/useAgentSession.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export interface UseAgentSessionOptions {
178178
newSessionDraftKey: string | null;
179179
/** A run ended; `aborted` when it was stopped rather than finished (pi's `agent_settled.aborted`). */
180180
onAgentEnd?: (end: AgentEndInfo) => void;
181+
/** A top-level tool call finished; the files it touched may have changed. */
182+
onToolEnd?: (toolName: string) => void;
181183
onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void;
182184
onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => void;
183185
onSessionForked?: (newSessionId: string) => void;
@@ -337,7 +339,7 @@ type SlashCommandsResponse = {
337339

338340
export function useAgentSession(opts: UseAgentSessionOptions) {
339341
const {
340-
session, newSessionCwd, newSessionDraftKey, onAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
342+
session, newSessionCwd, newSessionDraftKey, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
341343
modelsRefreshKey, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen,
342344
onOpenSettings,
343345
} = opts;
@@ -1653,6 +1655,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
16531655
case "tool_execution_end": {
16541656
if (isNestedToolExecutionEvent(event)) break;
16551657
const id = event.toolCallId as string;
1658+
onToolEnd?.(event.toolName as string);
16561659
setActiveToolResults((prev) => {
16571660
if (!prev.has(id)) return prev;
16581661
const next = new Map(prev);
@@ -1703,7 +1706,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
17031706
setExtensionDialogs((queue) => removeExtensionUiRequest(queue, event.id as string));
17041707
break;
17051708
}
1706-
}, [addNotice, applyContextUsage, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, refreshContextUsage, scheduleEventStreamClose, scrollToBottom, settleUiStage, syncLiveModel]);
1709+
}, [addNotice, applyContextUsage, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, onToolEnd, refreshContextUsage, scheduleEventStreamClose, scrollToBottom, settleUiStage, syncLiveModel]);
17071710
handleAgentEventRef.current = handleAgentEvent;
17081711

17091712
const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => {

0 commit comments

Comments
 (0)