a screen can hear when a chat turn finished

ChatPanelWrapper already refreshed the file tree and the preview when a turn
ran tools. it kept that to itself, so a screen whose own panels show something
a turn can change had no way to know — which is every plugin with a chat beside
a list.

so onTurnComplete is a prop now, composed with the platform's own handling
rather than replacing it. called for EVERY turn, outside the hadToolCalls
guard, because "did anything I care about change" is a question about the
caller's screen and not about this one — it gets the flag and decides.

read through a ref: a caller passing an inline arrow would otherwise rebuild
the callback every render, and it goes into EmbeddableChat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 19:03:34 +00:00
co-authored by Claude Opus 5
parent f9f4753425
commit 9a3b5dd649
@@ -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],
);