diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 94f51c60..13290d9a 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Link } from 'react-router'; import { useWorkspace } from '../../components/Workspace'; import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels'; @@ -164,9 +164,26 @@ type ChatPanelWrapperProps = { * platform mounts, not a caller that could pass this. */ chatContext?: { context: string; contextId?: string }; + /** + * Called when a turn ends, with whether it ran any tools. + * + * The platform's own refreshes — the file tree, the preview — always happen; this is composed on top for + * a screen whose panels show something a turn can change. A plugin's lists are the case: an agent that + * deletes a proxy host leaves every panel beside it describing a world that no longer exists, and the + * only signal a panel gets is this one. + * + * `hadToolCalls` is the whole discrimination worth making. A turn that only talked changed nothing, and + * re-reading after every sentence would put a request behind every reply. + */ + onTurnComplete?: (hadToolCalls: boolean) => void; }; -export const ChatPanelWrapper = ({ panelId, promptPrefix, chatContext: declared }: ChatPanelWrapperProps) => { +export const ChatPanelWrapper = ({ + panelId, + promptPrefix, + chatContext: declared, + onTurnComplete: alsoOnTurnComplete, +}: ChatPanelWrapperProps) => { const { workspace, cwd, root } = useWorkspace(); const scoped = cwd !== '~'; @@ -198,12 +215,20 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix, chatContext: declared const [, setPreviewRefresh] = usePreviewRefresh(); const [, bumpFilesRefresh] = useFilesRefresh(); + // The caller's handler is read through a ref rather than named as a dependency: a plugin passing an + // inline arrow would otherwise rebuild this callback every render, and it goes into EmbeddableChat. + const alsoRef = useRef(alsoOnTurnComplete); + alsoRef.current = alsoOnTurnComplete; + const onTurnComplete = useCallback( (hadToolCalls: boolean) => { if (hadToolCalls) { setPreviewRefresh(Date.now()); bumpFilesRefresh(); } + // Outside the `hadToolCalls` guard deliberately — the caller is told about every turn and decides + // for itself, because "did anything change" is a question about that screen, not about this one. + alsoRef.current?.(hadToolCalls); }, [setPreviewRefresh, bumpFilesRefresh], );