PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← All changes | src/Agent/components/ChatMessages.jsx +223 -16 3.1.23.2.1 View file →
@@ -1,23 +1,40 @@
1 +import { useCanvasWorkflow } from '@agent/components/Canvas';
2 +import { ErrorMessage } from '@agent/components/ErrorMessage';
1 3 import { AgentMessage } from '@agent/components/messages/AgentMessage';
4 +import { ImageToolMessage } from '@agent/components/messages/ImageToolMessage';
2 5 import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
6 +import { ToolReceipt } from '@agent/components/messages/ToolReceipt';
3 7 import { UserMessage } from '@agent/components/messages/UserMessage';
4 8 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
5 9 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
6 10 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
7 -import { ScrollIntoViewOnce } from '@agent/components/ScrollIntoViewOnce';
8 11 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
9 12 import { useChatStore } from '@agent/state/chat';
10 13 import { useGlobalStore } from '@agent/state/global';
11 14 import { useWorkflowStore } from '@agent/state/workflows';
15 +import { ImageAcquisitionRequest } from '@agent/workflows/abilities/components/ImageAcquisitionRequest';
12 16 import {
17 + AbilityRun,
18 + hasRunComponent,
19 +} from '@agent/workflows/abilities/components/run';
20 +import {
13 21 createElement,
22 + Fragment,
14 23 useEffect,
15 24 useLayoutEffect,
16 25 useRef,
17 26 useState,
18 27 } from '@wordpress/element';
28 +import { decodeEntities } from '@wordpress/html-entities';
29 +import { __, sprintf } from '@wordpress/i18n';
19 30
31 +// The raw validation text names schema paths the user can do nothing with.
32 +const abilityLabel = (name) =>
33 + (window.extAgentData?.wpAbilities ?? [])
34 + .flatMap((category) => category.abilities ?? [])
35 + .find((ability) => ability.name === name)?.label || name;
36 +
20 37 export const ChatMessages = () => {
21 38 const { open } = useGlobalStore();
22 39 const { messages } = useChatStore();
23 40 const { getWorkflow } = useWorkflowStore();
@@ -22,19 +39,39 @@
22 39 const { messages } = useChatStore();
23 40 const { getWorkflow } = useWorkflowStore();
24 41 const workflow = getWorkflow();
25 42 const whenFinishedToolProps = useWhenFinishedToolProps();
43 + const canvasWorkflow = useCanvasWorkflow();
26 44 const whenFinishedComponent = workflow?.whenFinished?.component;
27 45 const [canScrollDown, setCanScrollDown] = useState(false);
28 46 const containerRef = useRef(null);
29 47 const isFreshPageLoad = useRef(true);
30 48 const [ready, setReady] = useState(false);
49 + const userScrolledAway = useRef(false);
50 + const confirmScrolledFor = useRef(null);
51 + // Remounting into another layout would otherwise animate the whole backlog past.
52 + const settling = useRef(true);
53 + const behavior = () => {
54 + if (!settling.current) return 'smooth';
55 + settling.current = false;
56 + return 'auto';
57 + };
31 58
59 + const lastId = messages.at(-1)?.id;
60 + const lastDetails = messages.at(-1)?.details;
61 + const pendingTool =
62 + messages.at(-1)?.type === 'tool' && !('result' in (lastDetails ?? {}));
63 + // Both render their own waiting state, so the shared status line doubles it.
64 + const awaitingPicker =
65 + pendingTool &&
66 + (lastDetails?.id === 'acquire-image' || hasRunComponent(lastDetails?.id));
67 +
32 68 // If last message is a user message, move it to the top
33 69 const isUserMessage = messages.at(-1)?.details?.role === 'user';
34 70
35 71 useEffect(() => {
36 - if (!containerRef.current || !open) return;
72 + // The frontend agent shows the chat even while the store says closed.
73 + if (!containerRef.current) return;
37 74 if (!isFreshPageLoad.current) return;
38 75 isFreshPageLoad.current = false;
39 76 // Scroll to the bottom of the chat container on load
40 77 const c = containerRef.current;
@@ -47,16 +84,99 @@
47 84 last?.scrollIntoView({ behavior: 'auto', block: 'start' });
48 85 setReady(true);
49 86 });
50 87 });
88 + // A hidden tab suspends animation frames, leaving the list invisible.
89 + const fallback = setTimeout(() => setReady(true), 500);
51 90 return () => {
52 91 cancelAnimationFrame(id);
53 92 cancelAnimationFrame(id2);
93 + clearTimeout(fallback);
54 94 isFreshPageLoad.current = true;
55 95 setReady(false);
56 96 };
57 97 }, [open]);
58 98
99 + // A manual scroll means the user left the live edge on purpose — stop
100 + // following until they send again.
101 + useEffect(() => {
102 + const c = containerRef.current;
103 + if (!c) return;
104 + const markScrolled = () => {
105 + userScrolledAway.current = true;
106 + };
107 + c.addEventListener('wheel', markScrolled, { passive: true });
108 + c.addEventListener('touchmove', markScrolled, { passive: true });
109 + return () => {
110 + c.removeEventListener('wheel', markScrolled);
111 + c.removeEventListener('touchmove', markScrolled);
112 + };
113 + }, []);
114 +
115 + useEffect(() => {
116 + if (isUserMessage) userScrolledAway.current = false;
117 + }, [isUserMessage, messages]);
118 +
119 + const pinTarget = whenFinishedToolProps?.id
120 + ? 'confirm'
121 + : awaitingPicker
122 + ? `picker-${lastId}`
123 + : null;
124 +
125 + // Follow new agent content while it streams in.
126 + useEffect(() => {
127 + if (!ready || isUserMessage) return;
128 + if (userScrolledAway.current) return;
129 + if (pinTarget) return;
130 + const c = containerRef.current;
131 + const last = c?.querySelector(
132 + '#extendify-agent-chat-scroll-area > :last-child',
133 + );
134 + if (!last) return;
135 + const id = requestAnimationFrame(() => {
136 + // block:'end' scrolls UP for already-visible content — only reveal overflow.
137 + const overflows =
138 + last.getBoundingClientRect().bottom > c.getBoundingClientRect().bottom;
139 + if (!overflows) return;
140 + last.scrollIntoView({ behavior: behavior(), block: 'end' });
141 + });
142 + return () => cancelAnimationFrame(id);
143 + }, [ready, isUserMessage, messages, pinTarget]);
144 +
145 + // A confirm or picker demands action — pin its reply so both stay visible.
146 + useEffect(() => {
147 + if (!pinTarget) {
148 + confirmScrolledFor.current = null;
149 + return;
150 + }
151 + if (!ready || confirmScrolledFor.current === pinTarget) return;
152 + const c = containerRef.current;
153 + const scrollArea = c?.querySelector('#extendify-agent-chat-scroll-area');
154 + const tool = scrollArea?.lastElementChild;
155 + if (!c || !scrollArea || !tool) return;
156 + const pinWhenOutOfView = () => {
157 + if (confirmScrolledFor.current === pinTarget) return;
158 + const cRect = c.getBoundingClientRect();
159 + const toolRect = tool.getBoundingClientRect();
160 + if (toolRect.top >= cRect.top && toolRect.bottom <= cRect.bottom) return;
161 + confirmScrolledFor.current = pinTarget;
162 + const replies = c.querySelectorAll(
163 + '[data-agent-message-role="assistant"]',
164 + );
165 + const target = replies[replies.length - 1] ?? tool;
166 + const offset =
167 + target.getBoundingClientRect().top -
168 + scrollArea.getBoundingClientRect().top;
169 + scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
170 + target.scrollIntoView({ behavior: behavior(), block: 'start' });
171 + };
172 + pinWhenOutOfView();
173 + // The confirm grows while its preview loads and can leave the viewport.
174 + const observer = new ResizeObserver(pinWhenOutOfView);
175 + observer.observe(tool);
176 + return () => observer.disconnect();
177 + }, [ready, pinTarget]);
178 +
59 179 // Handles scrolling to the top of the last user message
60 180 // TODO: if the user sends in a long message, maybe we scroll to the bottom
61 181 // of the message offset by 2-3 lines
62 182 useEffect(() => {
@@ -66,18 +186,30 @@
66 186 const messages = c.querySelectorAll('[data-agent-message-role="user"]');
67 187 const last = messages[messages.length - 1];
68 188 if (!last || messages.length < 2) return;
69 189 const scrollArea = c.querySelector('#extendify-agent-chat-scroll-area');
70 - const lastRect = last.getBoundingClientRect();
71 - const innerHeight = Array.from(scrollArea.children).reduce(
72 - (sum, child) => sum + child.offsetHeight,
73 - 0,
74 - );
75 - const minHeight = innerHeight + c.clientHeight - lastRect.height;
76 - scrollArea.style.minHeight = `${minHeight}px`;
190 + // Reaching the top needs a viewport of room below the message's own offset.
191 + const offset =
192 + last.getBoundingClientRect().top - scrollArea.getBoundingClientRect().top;
193 + scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
77 194 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
78 195 }, [isUserMessage, messages]);
79 196
197 + // Chasing suggestions from far up the transcript would move the page.
198 + const lastIsWorkflow = messages.at(-1)?.type === 'workflow';
199 + useEffect(() => {
200 + if (!lastIsWorkflow) return;
201 + const c = containerRef.current;
202 + const last = c?.querySelector(
203 + '#extendify-agent-chat-scroll-area > :last-child',
204 + );
205 + if (!last) return;
206 + const below =
207 + last.getBoundingClientRect().top - c.getBoundingClientRect().bottom;
208 + if (below > c.clientHeight * 2) return;
209 + last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
210 + }, [lastIsWorkflow, messages]);
211 +
80 212 // Handles the scroll down button visibility
81 213 useLayoutEffect(() => {
82 214 const c = containerRef.current;
83 215 if (!c) return;
@@ -110,9 +242,9 @@
110 242 return (
111 243 <div
112 244 ref={containerRef}
113 245 style={{ overscrollBehavior: 'contain' }}
114 - className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2 scheme-light"
246 + className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2"
115 247 >
116 248 <div
117 249 id="extendify-agent-chat-scroll-area"
118 250 className={ready ? '' : 'invisible pointer-events-none'}
@@ -126,8 +258,9 @@
126 258 return (
127 259 <AgentMessage
128 260 key={message.id}
129 261 animate={!freshLoad}
262 + active={message.id === messages.at(-1)?.id}
130 263 message={message}
131 264 />
132 265 );
133 266 }
@@ -136,18 +269,92 @@
136 269 }
137 270 if (message.type === 'workflow-component') {
138 271 return <WorkflowComponent key={message.id} message={message} />;
139 272 }
273 + if (message.type === 'canvas-notice') {
274 + return (
275 + <ToolReceipt key={message.id}>
276 + {
277 + // translators: Shown in the agent chat when the user asks for something the open canvas cannot do. Canvas is the panel open on screen beside the chat.
278 + __(
279 + 'Canvas interactions are limited. When finished, use the button in the top corner to exit.',
280 + 'extendify-local',
281 + )
282 + }
283 + </ToolReceipt>
284 + );
285 + }
286 + if (
287 + message.type === 'tool' &&
288 + message.details?.id === 'acquire-image'
289 + ) {
290 + // Unanswered and last is still live; anything earlier is history.
291 + if (!('result' in message.details) && message.id === lastId) {
292 + return (
293 + <ImageAcquisitionRequest key={message.id} message={message} />
294 + );
295 + }
296 + // Only an answered picker reports a skip; anything else got no image.
297 + const answer = message.details?.result;
298 + return (
299 + <ImageToolMessage
300 + key={message.id}
301 + url={answer?.url}
302 + failed={!!answer && !answer.url && !answer.skipped}
303 + />
304 + );
305 + }
306 + if (message.type === 'tool') {
307 + const failure = message.details?.result?.error;
308 + const runnable = hasRunComponent(message.details?.id);
309 + // A run component reports its own outcome; a second line repeats it.
310 + const receipt = !runnable && !failure && message.details?.label;
311 + if (!runnable && !failure && !receipt) return null;
312 + return (
313 + <Fragment key={message.id}>
314 + {runnable ? (
315 + <AbilityRun
316 + id={message.details.id}
317 + inputs={message.details.inputs}
318 + result={message.details.result}
319 + />
320 + ) : null}
321 + {failure ? (
322 + <ErrorMessage>
323 + {sprintf(
324 + // translators: %s is the name of the task the agent tried to run.
325 + __('Error running %s.', 'extendify-local'),
326 + abilityLabel(message.details.id),
327 + )}
328 + </ErrorMessage>
329 + ) : null}
330 + {receipt ? (
331 + <ToolReceipt>{decodeEntities(receipt)}</ToolReceipt>
332 + ) : null}
333 + </Fragment>
334 + );
335 + }
336 + // Otherwise the live picker sits under its own receipt.
337 + if (message.type === 'image') {
338 + const live =
339 + !message.details?.url &&
340 + message.id === lastId &&
341 + whenFinishedToolProps?.id;
342 + if (live) return null;
343 + return (
344 + <ImageToolMessage key={message.id} url={message.details?.url} />
345 + );
346 + }
140 347 return null;
141 348 })}
142 - <StatusIndicator />
349 + {/* The tool-running status reads as stuck while the picker waits on the user. */}
350 + {awaitingPicker ? null : <StatusIndicator />}
143 351 {!workflow?.needsRedirect?.() &&
144 352 whenFinishedToolProps?.id &&
145 - whenFinishedComponent ? (
146 - <ScrollIntoViewOnce>
147 - {createElement(whenFinishedComponent, whenFinishedToolProps)}
148 - </ScrollIntoViewOnce>
149 - ) : null}
353 + !canvasWorkflow &&
354 + whenFinishedComponent
355 + ? createElement(whenFinishedComponent, whenFinishedToolProps)
356 + : null}
150 357 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
151 358 </div>
152 359 <ScrollDownButton
153 360 canScrollDown={canScrollDown}