← All changes
|
src/Agent/components/messages/StatusIndicator.jsx
+121
-42
3.1.3
→
3.2.2
View file →
| @@ -1,62 +1,141 @@ | ||
| 1 | -import { AnimateChunks } from '@agent/components/messages/AnimateChunks'; | |
| 1 | +import { useChatStore } from '@agent/state/chat'; | |
| 2 | 2 | import { useStatusStore } from '@agent/state/status'; |
| 3 | +import { hasRunComponent } from '@agent/workflows/abilities/components/run'; | |
| 3 | 4 | import { useEffect, useMemo, useState } from '@wordpress/element'; |
| 4 | 5 | import { decodeEntities } from '@wordpress/html-entities'; |
| 5 | 6 | import { __ } from '@wordpress/i18n'; |
| 6 | -import classNames from 'classnames'; | |
| 7 | 7 | |
| 8 | -const canAnimateTypes = ['calling-agent', 'agent-working', 'tool-started']; | |
| 8 | +const linesFor = (label) => ({ | |
| 9 | + 'calling-agent': __('Thinking...', 'extendify-local'), | |
| 10 | + 'agent-working': [ | |
| 11 | + __('Working on it...', 'extendify-local'), | |
| 12 | + __('Interpreting message...', 'extendify-local'), | |
| 13 | + __('Formulating a response...', 'extendify-local'), | |
| 14 | + __('Reviewing logic...', 'extendify-local'), | |
| 15 | + __('Checking the details...', 'extendify-local'), | |
| 16 | + __('Weighing the options...', 'extendify-local'), | |
| 17 | + __('Putting it together...', 'extendify-local'), | |
| 18 | + __('Still working on it...', 'extendify-local'), | |
| 19 | + ], | |
| 20 | + 'workflow-tool-processing': label || __('Processing...', 'extendify-local'), | |
| 21 | + 'tool-started': label || __('Gathering data...', 'extendify-local'), | |
| 22 | + 'credits-exhausted': __('Usage limit reached', 'extendify-local'), | |
| 23 | + 'credits-restored': __('Usage limit restored', 'extendify-local'), | |
| 24 | +}); | |
| 9 | 25 | |
| 10 | -export const StatusIndicator = () => { | |
| 11 | - const status = useStatusStore((s) => s.statuses.at(-1)); | |
| 12 | - const { type, label } = status ?? {}; | |
| 13 | - const [index, setIndex] = useState(0); | |
| 14 | - const statusContent = useMemo( | |
| 15 | - () => ({ | |
| 16 | - 'calling-agent': __('Thinking...', 'extendify-local'), | |
| 17 | - 'agent-working': [ | |
| 18 | - __('Working on it...', 'extendify-local'), | |
| 19 | - __('Interpreting message...', 'extendify-local'), | |
| 20 | - __('Formulating a response...', 'extendify-local'), | |
| 21 | - __('Reviewing logic...', 'extendify-local'), | |
| 22 | - ], | |
| 23 | - 'workflow-tool-processing': __('Processing...', 'extendify-local'), | |
| 24 | - 'tool-started': label || __('Gathering data...', 'extendify-local'), | |
| 25 | - 'tool-completed': label || __('Analyzing...', 'extendify-local'), | |
| 26 | - 'credits-exhausted': __('Usage limit reached', 'extendify-local'), | |
| 27 | - 'credits-restored': __('Usage limit restored', 'extendify-local'), | |
| 28 | - }), | |
| 29 | - [label], | |
| 30 | - ); | |
| 31 | - const content = statusContent[type]; | |
| 32 | - const isList = Array.isArray(content); | |
| 26 | +const beat = () => 3000 + Math.random() * 2000; | |
| 33 | 27 | |
| 34 | - useEffect(() => setIndex(0), [type]); | |
| 28 | +// Randomized 3-5s beat so a long wait never looks stuck or syncs across sites. | |
| 29 | +// Kept across status changes, or 3s tool steps restart every beat before it lands. | |
| 30 | +const useCurrentLine = (requestId, lines) => { | |
| 31 | + const [tick, setTick] = useState({ requestId, count: 0, due: 0 }); | |
| 32 | + const current = tick.requestId === requestId; | |
| 33 | + const count = current ? tick.count : 0; | |
| 34 | + const due = current ? tick.due : 0; | |
| 35 | 35 | |
| 36 | 36 | useEffect(() => { |
| 37 | - if (!isList) return; | |
| 38 | - // Cycle verbs on a randomized 3-5s beat so it never looks stuck. | |
| 37 | + if (!lines) return; | |
| 38 | + if (!due) { | |
| 39 | + setTick({ requestId, count, due: Date.now() + beat() }); | |
| 40 | + return; | |
| 41 | + } | |
| 39 | 42 | const timer = setTimeout( |
| 40 | - () => setIndex((i) => (i + 1) % content.length), | |
| 41 | - 3000 + Math.random() * 2000, | |
| 43 | + () => setTick({ requestId, count: count + 1, due: Date.now() + beat() }), | |
| 44 | + Math.max(0, due - Date.now()), | |
| 42 | 45 | ); |
| 43 | 46 | return () => clearTimeout(timer); |
| 44 | - }, [isList, content, index]); | |
| 47 | + }, [lines, requestId, count, due]); | |
| 45 | 48 | |
| 46 | - if (!type || !content) return null; | |
| 47 | - const text = isList ? content[index] : content; | |
| 49 | + if (!Array.isArray(lines)) return lines; | |
| 50 | + return lines[Math.min(count, lines.length - 1)]; | |
| 51 | +}; | |
| 48 | 52 | |
| 53 | +export const useStatusLine = () => { | |
| 54 | + const leavingPage = useStatusStore((s) => s.leavingPage); | |
| 55 | + const last = useChatStore((s) => s.messages.at(-1)); | |
| 56 | + const requestId = useChatStore( | |
| 57 | + (s) => | |
| 58 | + s.messages.findLast( | |
| 59 | + (m) => m.type === 'message' && m.details?.role === 'user', | |
| 60 | + )?.id, | |
| 61 | + ); | |
| 62 | + // A live picker shows its own waiting state; the status line would repeat it. | |
| 63 | + const awaitingPicker = | |
| 64 | + last?.type === 'tool' && | |
| 65 | + !('result' in (last.details ?? {})) && | |
| 66 | + (last.details?.id === 'acquire-image' || hasRunComponent(last.details?.id)); | |
| 67 | + const { type, label } = useStatusStore((s) => s.statuses.at(-1)) ?? {}; | |
| 68 | + const lines = useMemo(() => linesFor(label)[type], [label, type]); | |
| 69 | + const currentLine = useCurrentLine(requestId, lines); | |
| 70 | + const text = leavingPage | |
| 71 | + ? __('Refreshing the page to show your changes…', 'extendify-local') | |
| 72 | + : currentLine; | |
| 73 | + | |
| 74 | + if (!text || awaitingPicker) return null; | |
| 75 | + return decodeEntities(text); | |
| 76 | +}; | |
| 77 | + | |
| 78 | +const randomWave = (startY) => { | |
| 79 | + const cycles = 1.5 + Math.random(); | |
| 80 | + const offset = startY - 12; | |
| 81 | + const amplitude = Math.max(3.5 + Math.random() * 2.5, Math.abs(offset)); | |
| 82 | + const rise = Math.asin(offset / amplitude); | |
| 83 | + const phase = Math.random() < 0.5 ? rise : Math.PI - rise; | |
| 84 | + const points = Array.from({ length: 31 }, (_, i) => { | |
| 85 | + const t = i / 30; | |
| 86 | + const y = 12 + amplitude * Math.sin(Math.PI * 2 * cycles * t + phase); | |
| 87 | + return [3 + 18 * t, y]; | |
| 88 | + }); | |
| 89 | + return { | |
| 90 | + d: `M${points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L')}`, | |
| 91 | + endY: points.at(-1)[1], | |
| 92 | + }; | |
| 93 | +}; | |
| 94 | + | |
| 95 | +export const StatusScribble = () => { | |
| 96 | + const [wave, setWave] = useState(() => randomWave(12)); | |
| 49 | 97 | return ( |
| 98 | + <svg | |
| 99 | + viewBox="0 0 24 24" | |
| 100 | + width="16" | |
| 101 | + height="16" | |
| 102 | + fill="none" | |
| 103 | + aria-hidden="true" | |
| 104 | + className="shrink-0 text-design-main" | |
| 105 | + > | |
| 106 | + <path | |
| 107 | + className="status-scribble" | |
| 108 | + d={wave.d} | |
| 109 | + onAnimationIteration={() => setWave((w) => randomWave(w.endY))} | |
| 110 | + stroke="currentColor" | |
| 111 | + strokeWidth="2" | |
| 112 | + strokeLinecap="round" | |
| 113 | + strokeLinejoin="round" | |
| 114 | + pathLength="1" | |
| 115 | + /> | |
| 116 | + </svg> | |
| 117 | + ); | |
| 118 | +}; | |
| 119 | + | |
| 120 | +export const StatusIndicator = () => { | |
| 121 | + const line = useStatusLine(); | |
| 122 | + // Every chat message clears the statuses; unmounting then cuts the icon's loop short. | |
| 123 | + return ( | |
| 50 | 124 | <div |
| 51 | - className={classNames('p-2 text-center text-xs italic text-gray-700', { | |
| 52 | - 'status-animation': canAnimateTypes.includes(type), | |
| 53 | - })} | |
| 125 | + data-status-indicator | |
| 126 | + className={ | |
| 127 | + line | |
| 128 | + ? 'flex items-center gap-1.5 px-1 pb-1.5 text-sm italic text-gray-700' | |
| 129 | + : 'invisible flex h-0 overflow-hidden' | |
| 130 | + } | |
| 54 | 131 | > |
| 55 | - <AnimateChunks | |
| 56 | - key={`${type}-${index}`} | |
| 57 | - words={decodeEntities(text).split('')} | |
| 58 | - delay={0.02} | |
| 59 | - /> | |
| 132 | + <StatusScribble /> | |
| 133 | + {/* Reusing the node drops the new line mid-sweep. */} | |
| 134 | + {line ? ( | |
| 135 | + <span key={line} className="status-animation"> | |
| 136 | + {line} | |
| 137 | + </span> | |
| 138 | + ) : null} | |
| 60 | 139 | </div> |
| 61 | 140 | ); |
| 62 | 141 | }; |