1/** 2 * Turn-status narration. Renders the work currently in flight from 3 * server-emitted `phase` events. No spinner, no cycling copy, no icons â 4 * the visible text is the last honest phase label. 5 * 6 * Every visible TEXT state MUST correspond to a server-emitted phase or 7 * payload field â never invent â except the static LABELS fallback when 8 * the server sends a known phase with no label. 9 */ 10 11const LABELS = { 12 connected: 'Opening the chat stream.', 13 assembling_context: 'Preparing the response.', 14 searching_memory: 'Searching memory context.', 15 calling_tools: 'Calling tools.', 16 thinking: 'Thinking it through.', 17 streaming: '', 18 persisted: '', 19}; 20 21const RESPONSE_MODE_TIER = { fast: 0, context: 1, deep: 2 }; 22const MODE_TO_LANE = { fast: 'ask', context: 'work', deep: 'think' }; 23const LANE_TO_MODE = Object.fromEntries(Object.entries(MODE_TO_LANE).map(([mode, lane]) => [lane, mode])); 24 25const TOOL_ACTION_LABELS = { 26 search_memory: 'Searching your memory', 27 search_people: 'Looking through your contacts', 28 query_network: 'Mapping your network', 29 query_family: 'Checking your family info', 30 add_fact: 'Saving a fact', 31 add_person: 'Adding that person', 32 set_relation_tag: 'Updating your relationships', 33 set_employer: 'Updating your work info', 34 update_person: 'Updating that record', 35 update_profile: 'Updating your profile', 36 get_health_summary: 'Checking your health summary', 37 record_viewer_correction: 'Applying your correction', 38}; 39const TOOL_ACTION_FALLBACK = 'Working on itâ¦'; 40 41function resolveMode(payload) { 42 const mode = payload?.mode; 43 if (Object.prototype.hasOwnProperty.call(RESPONSE_MODE_TIER, mode)) return mode; 44 const fromLane = LANE_TO_MODE[payload?.lane]; 45 return fromLane || 'fast'; 46} 47 48function humanizeToolPhase(payload) { 49 const rawLabel = typeof payload?.label === 'string' ? payload.label.trim() : ''; 50 if (!rawLabel) { 51 return typeof payload?.count === 'number' 52 ? composeCountLabel('calling_tools', payload.count) 53 : TOOL_ACTION_FALLBACK; 54 } 55 const match = rawLabel.match(/^calling\s+(.+?)[.â¦]*$/i); 56 const name = (match ? match[1] : rawLabel).trim(); 57 const key = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); 58 return TOOL_ACTION_LABELS[key] || TOOL_ACTION_FALLBACK; 59} 60 61export class Indicator { 62 /** 63 * @param {Element} domEl - the `.chat-indicator` container. Must contain 64 * a `.chat-indicator-icon` and a `.chat-indicator-text` child. 65 */ 66 constructor(domEl) { 67 this._el = domEl; 68 this._iconEl = domEl?.querySelector?.('.chat-indicator-icon') || null; 69 this._textEl = domEl?.querySelector?.('.chat-indicator-text') || null; 70 this._lastPhase = null; 71 this._tier = null; 72 this._lane = null; 73 this._holdTimer = null; 74 this._providerDegradedMessage = null; 75 } 76 77 /** 78 * Render a phase label into the DOM. Unknown phases are no-ops. 79 * 80 * @param {string} phaseName 81 * @param {object} [payload] 82 */ 83 setPhase(phaseName, payload) { 84 if (!Object.prototype.hasOwnProperty.call(LABELS, phaseName)) return; 85 this._lastPhase = phaseName; 86 87 if (payload && payload.degraded === true && typeof payload.label === 'string' && payload.label.trim()) { 88 this._providerDegradedMessage = payload.label.trim(); 89 } 90 if (this._providerDegradedMessage) { 91 this._render(this._providerDegradedMessage); 92 if (this._el?.dataset) this._el.dataset.providerDegraded = 'true'; 93 return; 94 } 95 96 if (phaseName === 'assembling_context') { 97 const mode = resolveMode(payload); 98 this._tier = RESPONSE_MODE_TIER[mode] ?? 0; 99 this._lane = MODE_TO_LANE[mode] || 'ask'; 100 if (this._el?.dataset) this._el.dataset.tier = this._lane; 101 } 102 103 let text = ''; 104 if (phaseName === 'calling_tools') { 105 text = humanizeToolPhase(payload); 106 } else if (payload && typeof payload === 'object') { 107 if (typeof payload.label === 'string' && payload.label.trim()) { 108 text = payload.label.trim(); 109 } else if (typeof payload.count === 'number') { 110 text = composeCountLabel(phaseName, payload.count); 111 } else { 112 text = LABELS[phaseName]; 113 } 114 } else { 115 text = LABELS[phaseName]; 116 } 117 118 if (!text) return; 119 this._render(text); 120 } 121 122 fadeOut() { 123 if (this._el && this._el.classList) { 124 this._el.classList.add('is-fading'); 125 this._el.classList.remove('is-active'); 126 } 127 } 128 129 clear() { 130 this._lastPhase = null; 131 this._tier = null; 132 this._lane = null; 133 this._providerDegradedMessage = null; 134 this._holdTimer = null; 135 if (this._textEl) this._textEl.textContent = ''; 136 else if (this._el) this._el.textContent = ''; 137 if (this._iconEl) { 138 this._iconEl.innerHTML = ''; 139 if (this._iconEl.classList) { 140 this._iconEl.classList.remove('ask'); 141 this._iconEl.classList.remove('work'); 142 this._iconEl.classList.remove('think'); 143 } 144 this._iconEl.removeAttribute?.('data-tip'); 145 } 146 if (this._el) { 147 if (this._el.dataset) { 148 delete this._el.dataset.phase; 149 delete this._el.dataset.status; 150 delete this._el.dataset.tier; 151 delete this._el.dataset.providerDegraded; 152 } 153 this._el.removeAttribute?.('aria-label'); 154 if (this._el.classList) { 155 this._el.classList.remove('is-fading'); 156 this._el.classList.remove('is-active'); 157 } 158 } 159 } 160 161 getPhase() { 162 return this._lastPhase; 163 } 164 165 isProviderDegraded() { 166 return Boolean(this._providerDegradedMessage); 167 } 168 169 _render(text) { 170 if (this._textEl) this._textEl.textContent = text; 171 else if (this._el) this._el.textContent = text; 172 if (this._el?.dataset) { 173 this._el.dataset.slaManaged = 'true'; 174 if (this._lastPhase) this._el.dataset.phase = this._lastPhase;
175 const value = String(text || '').trim(); 176 if (value) { 177 this._el.dataset.status = value; 178 this._el.setAttribute?.('aria-label', value); 179 } 180 } 181 if (this._el?.classList) { 182 this._el.classList.add('is-active'); 183 this._el.classList.remove('is-fading'); 184 } 185 } 186} 187 188export function composeCountLabel(phase, count) { 189 const n = Number.isFinite(count) ? count : 0; 190 switch (phase) { 191 case 'searching_memory': return `Reading ${n} of your sourcesâ¦`; 192 case 'calling_tools': return `Calling ${n} toolsâ¦`; 193 case 'assembling_context': return `Adding context from ${n} sources.`; 194 default: return LABELS[phase] || ''; 195 } 196} 197 198export { LABELS as INDICATOR_LABELS }; 199export { RESPONSE_MODE_TIER, resolveMode, humanizeToolPhase };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.