← All changes
|
src/Agent/components/messages/StatusIndicator.jsx
+101
-18
3.2.0
→
3.2.2
View file →
| @@ -1,5 +1,7 @@ | ||
| 1 | +import { useChatStore } from '@agent/state/chat'; | |
| 1 | 2 | import { useStatusStore } from '@agent/state/status'; |
| 3 | +import { hasRunComponent } from '@agent/workflows/abilities/components/run'; | |
| 2 | 4 | import { useEffect, useMemo, useState } from '@wordpress/element'; |
| 3 | 5 | import { decodeEntities } from '@wordpress/html-entities'; |
| 4 | 6 | import { __ } from '@wordpress/i18n'; |
| 5 | 7 | |
| @@ -9,50 +11,131 @@ | ||
| 9 | 11 | __('Working on it...', 'extendify-local'), |
| 10 | 12 | __('Interpreting message...', 'extendify-local'), |
| 11 | 13 | __('Formulating a response...', 'extendify-local'), |
| 12 | 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'), | |
| 13 | 19 | ], |
| 14 | - 'workflow-tool-processing': __('Processing...', 'extendify-local'), | |
| 20 | + 'workflow-tool-processing': label || __('Processing...', 'extendify-local'), | |
| 15 | 21 | 'tool-started': label || __('Gathering data...', 'extendify-local'), |
| 16 | 22 | 'credits-exhausted': __('Usage limit reached', 'extendify-local'), |
| 17 | 23 | 'credits-restored': __('Usage limit restored', 'extendify-local'), |
| 18 | 24 | }); |
| 19 | 25 | |
| 26 | +const beat = () => 3000 + Math.random() * 2000; | |
| 27 | + | |
| 20 | 28 | // Randomized 3-5s beat so a long wait never looks stuck or syncs across sites. |
| 21 | -const useCurrentLine = (lines) => { | |
| 22 | - const [index, setIndex] = useState(0); | |
| 23 | - const isList = Array.isArray(lines); | |
| 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; | |
| 24 | 35 | |
| 25 | - useEffect(() => setIndex(0), [lines]); | |
| 26 | - | |
| 27 | 36 | useEffect(() => { |
| 28 | - if (!isList) return; | |
| 37 | + if (!lines) return; | |
| 38 | + if (!due) { | |
| 39 | + setTick({ requestId, count, due: Date.now() + beat() }); | |
| 40 | + return; | |
| 41 | + } | |
| 29 | 42 | const timer = setTimeout( |
| 30 | - () => setIndex((i) => (i + 1) % lines.length), | |
| 31 | - 3000 + Math.random() * 2000, | |
| 43 | + () => setTick({ requestId, count: count + 1, due: Date.now() + beat() }), | |
| 44 | + Math.max(0, due - Date.now()), | |
| 32 | 45 | ); |
| 33 | 46 | return () => clearTimeout(timer); |
| 34 | - }, [isList, lines, index]); | |
| 47 | + }, [lines, requestId, count, due]); | |
| 35 | 48 | |
| 36 | - return isList ? lines[index] : lines; | |
| 49 | + if (!Array.isArray(lines)) return lines; | |
| 50 | + return lines[Math.min(count, lines.length - 1)]; | |
| 37 | 51 | }; |
| 38 | 52 | |
| 39 | -export const StatusIndicator = () => { | |
| 53 | +export const useStatusLine = () => { | |
| 40 | 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)); | |
| 41 | 67 | const { type, label } = useStatusStore((s) => s.statuses.at(-1)) ?? {}; |
| 42 | 68 | const lines = useMemo(() => linesFor(label)[type], [label, type]); |
| 43 | - const currentLine = useCurrentLine(lines); | |
| 69 | + const currentLine = useCurrentLine(requestId, lines); | |
| 44 | 70 | const text = leavingPage |
| 45 | 71 | ? __('Refreshing the page to show your changes…', 'extendify-local') |
| 46 | 72 | : currentLine; |
| 47 | 73 | |
| 48 | - if (!text) return null; | |
| 74 | + if (!text || awaitingPicker) return null; | |
| 75 | + return decodeEntities(text); | |
| 76 | +}; | |
| 49 | 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)); | |
| 50 | 97 | return ( |
| 51 | - <div className="p-2 text-center text-xs italic text-gray-700"> | |
| 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 ( | |
| 124 | + <div | |
| 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 | + } | |
| 131 | + > | |
| 132 | + <StatusScribble /> | |
| 52 | 133 | {/* Reusing the node drops the new line mid-sweep. */} |
| 53 | - <span key={text} className="status-animation"> | |
| 54 | - {decodeEntities(text)} | |
| 55 | - </span> | |
| 134 | + {line ? ( | |
| 135 | + <span key={line} className="status-animation"> | |
| 136 | + {line} | |
| 137 | + </span> | |
| 138 | + ) : null} | |
| 56 | 139 | </div> |
| 57 | 140 | ); |
| 58 | 141 | }; |