personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chat bar island: seamless state persistence across navigation

Three named states (resting/glance/focused) with full lifecycle:
- Input text and cursor position persist via localStorage + pagehide
- Glance state (inline responses) survives all navigations
- Focused auto-dismisses to glance on navigate
- In-flight requests auto-retry within 30s staleness window
- CSS class rename to BEM: app-bar--glance, app-bar--focused, app-bar--dismissing
- Websocket navigate handler calls closePanel before navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+139 -25
+6 -6
convey/static/app.css
··· 1041 1041 transition: max-height 0.2s ease, padding 0.2s ease; 1042 1042 } 1043 1043 1044 - .chat-bar-response-panel.chat-bar-panel-open { 1044 + .app-bar--glance .chat-bar-response-panel { 1045 1045 max-height: 200px; 1046 1046 padding: 12px 16px 0; 1047 1047 } ··· 1193 1193 } 1194 1194 1195 1195 /* Panel expanded state */ 1196 - .app-bar.panel-open { 1196 + .app-bar.app-bar--focused { 1197 1197 height: 80vh; 1198 1198 bottom: 0; 1199 1199 border-radius: 12px 12px 0 0; ··· 1202 1202 } 1203 1203 1204 1204 /* Panel closing animation */ 1205 - .app-bar.panel-closing { 1205 + .app-bar.app-bar--dismissing { 1206 1206 z-index: calc(var(--z-bars) + 50); 1207 1207 animation: panel-collapse 0.25s ease forwards; 1208 1208 } ··· 1250 1250 border-radius: 3px; 1251 1251 } 1252 1252 1253 - .app-bar.panel-open .conversation-messages { 1253 + .app-bar.app-bar--focused .conversation-messages { 1254 1254 display: flex; 1255 1255 } 1256 1256 1257 1257 /* Hide inline response panel in panel mode */ 1258 - .app-bar.panel-open .chat-bar-response-panel { 1258 + .app-bar.app-bar--focused .chat-bar-response-panel { 1259 1259 display: none; 1260 1260 } 1261 1261 ··· 1264 1264 display: none; 1265 1265 } 1266 1266 1267 - .app-bar.panel-open .conversation-separator { 1267 + .app-bar.app-bar--focused .conversation-separator { 1268 1268 display: block; 1269 1269 height: 1px; 1270 1270 background: var(--facet-border, #e0e0e0);
+1
convey/static/websocket.js
··· 161 161 162 162 // Built-in tract: navigate browser to a path and/or switch facet 163 163 listeners['navigate'] = [function(msg) { 164 + if (window._closeConversationPanel) window._closeConversationPanel(); 164 165 if (msg.facet && !msg.path) { 165 166 window.selectFacet && window.selectFacet(msg.facet); 166 167 } else if (msg.path) {
+132 -19
convey/templates/app.html
··· 104 104 let panelOpen = false; 105 105 let messages = []; 106 106 let savedScrollTop = 0; 107 + let pendingMessage = null; 107 108 108 109 // --- State persistence --- 109 110 function save() { 110 111 try { 111 112 localStorage.setItem(STORE_KEY, JSON.stringify({ 112 113 messages: messages, 113 - scrollTop: msgArea ? msgArea.scrollTop : 0 114 + scrollTop: msgArea ? msgArea.scrollTop : 0, 115 + inputText: input.value, 116 + inputSelection: [input.selectionStart, input.selectionEnd], 117 + pendingMessage: pendingMessage 114 118 })); 115 119 } catch {} 116 120 } ··· 130 134 function clearConversation() { 131 135 messages = []; 132 136 savedScrollTop = 0; 137 + pendingMessage = null; 133 138 try { localStorage.removeItem(STORE_KEY); } catch {} 134 139 if (msgArea) msgArea.innerHTML = ''; 135 140 } ··· 150 155 function openPanel() { 151 156 if (panelOpen) return; 152 157 panelOpen = true; 153 - appBar.classList.remove('panel-closing'); 154 - appBar.classList.add('panel-open'); 158 + appBar.classList.remove('app-bar--dismissing'); 159 + appBar.classList.add('app-bar--focused'); 155 160 backdrop.classList.add('visible'); 156 161 renderMessages(); 157 162 responsePanel.style.display = 'none'; ··· 171 176 if (!panelOpen) return; 172 177 panelOpen = false; 173 178 save(); 174 - appBar.classList.add('panel-closing'); 179 + appBar.classList.add('app-bar--dismissing'); 175 180 backdrop.classList.remove('visible'); 176 181 177 182 function onDone() { 178 - appBar.classList.remove('panel-open', 'panel-closing'); 183 + appBar.classList.remove('app-bar--focused', 'app-bar--dismissing'); 179 184 responsePanel.style.display = ''; 180 185 } 181 186 appBar.addEventListener('animationend', onDone, { once: true }); 182 187 setTimeout(onDone, 300); // fallback 183 188 } 184 189 190 + // Expose for websocket navigate handler 191 + window._closeConversationPanel = closePanel; 192 + 193 + // Save state before navigation 194 + window.addEventListener('pagehide', save); 195 + 185 196 // --- Inline vs panel heuristic --- 186 197 function isOneLiner(text) { 187 198 if (!text) return true; ··· 199 210 openPanel(); 200 211 }); 201 212 202 - // --- Textarea auto-resize --- 213 + // --- Textarea auto-resize + debounced save --- 214 + let inputSaveTimer; 203 215 input.addEventListener('input', function() { 204 216 input.style.height = 'auto'; 205 217 input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 206 218 sendBtn.style.display = input.value.trim() ? '' : 'none'; 219 + clearTimeout(inputSaveTimer); 220 + inputSaveTimer = setTimeout(save, 300); 207 221 }); 208 222 209 223 // --- Focus on load --- ··· 213 227 214 228 // --- Restore from localStorage --- 215 229 var saved = load(); 216 - if (saved && messages.length > 0) { 217 - var last = null; 218 - for (var i = messages.length - 1; i >= 0; i--) { 219 - if (messages[i].role === 'agent') { last = messages[i]; break; } 230 + if (saved) { 231 + // Restore input text 232 + if (saved.inputText) { 233 + input.value = saved.inputText; 234 + input.style.height = 'auto'; 235 + input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 236 + sendBtn.style.display = input.value.trim() ? '' : 'none'; 220 237 } 221 - if (last && inlineResp) { 222 - inlineResp.textContent = last.content; 223 - inlineResp.style.display = ''; 224 - dismissBtn.style.display = ''; 225 - responsePanel.classList.add('chat-bar-panel-open'); 238 + 239 + // Restore cursor position 240 + if (saved.inputSelection) { 241 + try { input.setSelectionRange(saved.inputSelection[0], saved.inputSelection[1]); } catch {} 242 + } 243 + 244 + // Restore glance state (last agent response inline) 245 + if (messages.length > 0) { 246 + var last = null; 247 + for (var i = messages.length - 1; i >= 0; i--) { 248 + if (messages[i].role === 'agent') { last = messages[i]; break; } 249 + } 250 + if (last && inlineResp) { 251 + inlineResp.textContent = last.content; 252 + inlineResp.style.display = ''; 253 + dismissBtn.style.display = ''; 254 + appBar.classList.add('app-bar--glance'); 255 + } 256 + } 257 + 258 + // Auto-retry pending request if < 30 seconds old 259 + if (saved.pendingMessage && saved.pendingMessage.sentAt) { 260 + var age = Date.now() - saved.pendingMessage.sentAt; 261 + if (age < 30000) { 262 + pendingMessage = saved.pendingMessage; 263 + thinking.style.display = ''; 264 + inlineResp.style.display = 'none'; 265 + dismissBtn.style.display = 'none'; 266 + appBar.classList.add('app-bar--glance'); 267 + 268 + (async function() { 269 + try { 270 + var history = messages.slice(0, -1).map(function(m) { 271 + return { role: m.role, content: m.content }; 272 + }); 273 + var r = await fetch('/api/triage', { 274 + method: 'POST', 275 + headers: { 'Content-Type': 'application/json' }, 276 + body: JSON.stringify({ 277 + message: pendingMessage.text, 278 + app: '{{ app }}', 279 + path: window.location.pathname, 280 + facet: window.selectedFacet || null, 281 + conversation_history: history.length > 0 ? history : undefined 282 + }) 283 + }); 284 + 285 + var resp = ''; 286 + var panelHint = false; 287 + var errMsg = null; 288 + if (r.ok) { 289 + var data = await r.json(); 290 + resp = data.response || ''; 291 + panelHint = !!data.panel; 292 + } else { 293 + errMsg = 'Something went wrong. Try again.'; 294 + } 295 + var content = errMsg || resp; 296 + if (content) { 297 + messages.push({ role: 'agent', content: content, ts: Date.now() }); 298 + } 299 + pendingMessage = null; 300 + 301 + var userCount = messages.filter(function(m) { return m.role === 'user'; }).length; 302 + var shouldInline = userCount === 1 && !panelHint && isOneLiner(resp) && !errMsg; 303 + if (shouldInline) { 304 + thinking.style.display = 'none'; 305 + inlineResp.textContent = content; 306 + inlineResp.style.display = content ? '' : 'none'; 307 + dismissBtn.style.display = content ? '' : 'none'; 308 + if (!content) appBar.classList.remove('app-bar--glance'); 309 + } else { 310 + thinking.style.display = 'none'; 311 + appBar.classList.remove('app-bar--glance'); 312 + openPanel(); 313 + } 314 + save(); 315 + } catch (err) { 316 + pendingMessage = null; 317 + var errContent = 'Connection error. Try again.'; 318 + messages.push({ role: 'agent', content: errContent, ts: Date.now() }); 319 + thinking.style.display = 'none'; 320 + inlineResp.textContent = errContent; 321 + inlineResp.style.display = ''; 322 + dismissBtn.style.display = ''; 323 + save(); 324 + } finally { 325 + input.disabled = false; 326 + input.focus(); 327 + } 328 + })(); 329 + } else { 330 + // Stale pending request — abandon 331 + pendingMessage = null; 332 + save(); 333 + } 226 334 } 227 335 } 228 336 229 337 // --- Dismiss inline response --- 230 338 dismissBtn.addEventListener('click', function(e) { 231 339 e.stopPropagation(); 232 - responsePanel.classList.remove('chat-bar-panel-open'); 340 + appBar.classList.remove('app-bar--glance'); 233 341 thinking.style.display = 'none'; 234 342 inlineResp.style.display = 'none'; 235 343 dismissBtn.style.display = 'none'; ··· 251 359 if (!text) return; 252 360 253 361 messages.push({ role: 'user', content: text, ts: Date.now() }); 362 + pendingMessage = { text: text, sentAt: Date.now() }; 254 363 255 364 // Clear input 256 365 input.value = ''; 257 366 input.style.height = ''; 258 367 sendBtn.style.display = 'none'; 259 368 input.disabled = true; 369 + save(); 260 370 261 371 if (panelOpen) { 262 372 // Show user message + thinking indicator in panel ··· 272 382 thinking.style.display = ''; 273 383 inlineResp.style.display = 'none'; 274 384 dismissBtn.style.display = 'none'; 275 - responsePanel.classList.add('chat-bar-panel-open'); 385 + appBar.classList.add('app-bar--glance'); 276 386 } 277 387 278 388 try { ··· 308 418 if (content) { 309 419 messages.push({ role: 'agent', content: content, ts: Date.now() }); 310 420 } 421 + pendingMessage = null; 311 422 312 423 var userCount = messages.filter(function(m) { return m.role === 'user'; }).length; 313 424 var shouldInline = userCount === 1 && !panelHint && isOneLiner(resp) && !errMsg; ··· 321 432 inlineResp.textContent = content; 322 433 inlineResp.style.display = content ? '' : 'none'; 323 434 dismissBtn.style.display = content ? '' : 'none'; 324 - if (!content) responsePanel.classList.remove('chat-bar-panel-open'); 435 + if (!content) appBar.classList.remove('app-bar--glance'); 325 436 } else { 326 437 // Expand to panel 327 438 thinking.style.display = 'none'; 328 - responsePanel.classList.remove('chat-bar-panel-open'); 439 + appBar.classList.remove('app-bar--glance'); 329 440 openPanel(); 330 441 } 331 442 332 443 save(); 333 444 } catch (err) { 445 + pendingMessage = null; 334 446 var errContent = 'Connection error. Try again.'; 335 447 messages.push({ role: 'agent', content: errContent, ts: Date.now() }); 336 448 if (panelOpen) { ··· 342 454 inlineResp.style.display = ''; 343 455 dismissBtn.style.display = ''; 344 456 } 457 + save(); 345 458 } finally { 346 459 var pt = document.getElementById('panelThinking'); 347 460 if (pt) pt.remove();