| @@ -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} |