Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import type { ChatInputHandle } from "./ChatInput";
import type { AgentEndInfo, NewSessionChoices } from "@/hooks/useAgentSession";
import type { SessionStatsInfo } from "@/lib/pi-types";
import type { FileViewerState } from "@/lib/file-viewer-state";
import type { ToolEntry } from "@/lib/tool-presets";
import { PRESET_READ_ONLY, type ToolEntry } from "@/lib/tool-presets";
import { getSessionFamily } from "@/lib/session-family";
import { getLastSettingsSection, settingsSectionRequiresProject, type SettingsSection } from "@/lib/settings-navigation";

Expand All @@ -84,6 +84,9 @@ type AutoNameStatus =

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

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

// A run's writes show in the file tree as they happen, not only when it ends
// (#1144): at most one refresh a second, after any tool but the read-only ones.
const toolEndRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleToolEnd = useCallback((toolName: string) => {
if (READ_ONLY_TOOL_NAMES.has(toolName) || toolEndRefreshTimerRef.current) return;
toolEndRefreshTimerRef.current = setTimeout(() => {
toolEndRefreshTimerRef.current = null;
setExplorerRefreshKey((k) => k + 1);
}, TOOL_END_REFRESH_MS);
}, []);
useEffect(() => () => {
if (toolEndRefreshTimerRef.current) clearTimeout(toolEndRefreshTimerRef.current);
}, []);

const handleSessionForked = useCallback((newSessionId: string) => {
invalidateWorkspaceRestore();
activeNewSessionDraftKeyRef.current = null;
Expand Down Expand Up @@ -2519,6 +2536,7 @@ export function AppShell() {
initialNewSessionChoices={selectedSession === null ? carriedNewSessionChoices : null}
onNewSessionChoicesChange={handleNewSessionChoicesChange}
onAgentEnd={handleAgentEnd}
onToolEnd={handleToolEnd}
onAttentionNeeded={handleAttentionNeeded}
onSessionCreated={handleSessionCreated}
onSessionForked={handleSessionForked}
Expand Down
5 changes: 3 additions & 2 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface Props {
initialNewSessionChoices?: NewSessionChoices | null;
onNewSessionChoicesChange?: (choices: NewSessionChoices) => void;
onAgentEnd?: (end: AgentEndInfo) => void;
onToolEnd?: (toolName: string) => void;
onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void;
onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => void;
onSessionForked?: (newSessionId: string) => void;
Expand Down Expand Up @@ -247,7 +248,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, defaultExpanded = fa
);
}

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) {
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) {
const { t } = useI18n();
const isMobile = useIsMobile();
const completionNotificationsEnabled = session?.relation?.kind !== "subagent";
Expand Down Expand Up @@ -301,7 +302,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
handleToolPresetChange, handleThinkingLevelChange, handleSetDefaultModel, handleSetDefaultThinkingLevel, loadSlashCommands, scrollUserMsgToTop,
loadContext, activeLeafId, scrollToBottom, scrollToMessage,
} = useAgentSession({
session, sessionRunning, newSessionCwd, newSessionDraftKey, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
session, sessionRunning, newSessionCwd, newSessionDraftKey, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd: wrappedOnAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen,
onOpenSettings,
deferInitialScroll: Boolean(pendingScrollRestore),
Expand Down
8 changes: 8 additions & 0 deletions components/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,14 @@ export const FileExplorer = forwardRef<FileExplorerHandle, Props>(function FileE
return () => { cancelled = true; };
}, [cwd, refreshKey, treeRefreshKey, showHidden]);

// Files changed outside pi-web (an editor, a terminal) show up once the
// window is back in front (#1144).
useEffect(() => {
const refresh = () => setTreeRefreshKey((key) => key + 1);
window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", refresh);
}, []);

useEffect(() => {
let cancelled = false;
fetchGitStatus(cwd)
Expand Down
3 changes: 3 additions & 0 deletions docs/agents/files-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
- 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.
- 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.

## When the file tree reloads
- 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.

## Uploads and chat drops
- 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.
- 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.
Expand Down
7 changes: 5 additions & 2 deletions hooks/useAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export interface UseAgentSessionOptions {
newSessionDraftKey: string | null;
/** A run ended; `aborted` when it was stopped rather than finished (pi's `agent_settled.aborted`). */
onAgentEnd?: (end: AgentEndInfo) => void;
/** A top-level tool call finished; the files it touched may have changed. */
onToolEnd?: (toolName: string) => void;
onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void;
onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => void;
onSessionForked?: (newSessionId: string) => void;
Expand Down Expand Up @@ -337,7 +339,7 @@ type SlashCommandsResponse = {

export function useAgentSession(opts: UseAgentSessionOptions) {
const {
session, newSessionCwd, newSessionDraftKey, onAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
session, newSessionCwd, newSessionDraftKey, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
modelsRefreshKey, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen,
onOpenSettings,
} = opts;
Expand Down Expand Up @@ -1653,6 +1655,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
case "tool_execution_end": {
if (isNestedToolExecutionEvent(event)) break;
const id = event.toolCallId as string;
onToolEnd?.(event.toolName as string);
setActiveToolResults((prev) => {
if (!prev.has(id)) return prev;
const next = new Map(prev);
Expand Down Expand Up @@ -1703,7 +1706,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
setExtensionDialogs((queue) => removeExtensionUiRequest(queue, event.id as string));
break;
}
}, [addNotice, applyContextUsage, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, refreshContextUsage, scheduleEventStreamClose, scrollToBottom, settleUiStage, syncLiveModel]);
}, [addNotice, applyContextUsage, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, onToolEnd, refreshContextUsage, scheduleEventStreamClose, scrollToBottom, settleUiStage, syncLiveModel]);
handleAgentEventRef.current = handleAgentEvent;

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