PluginProbe
Extendify / trunk
Extendify vtrunk
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.3 → trunk 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}