personal memory agent
0
fork

Configure Feed

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

Replace per-app bottom bars with universal chat bar

Remove all five app_bar.html files (chat, todos, entities, search, dev)
and replace with a single universal chat bar rendered in app.html. The
bar is always visible at the bottom of every app.

In the chat app, the bar provides the same textarea and form elements
that workspace.html's initAppBarControls() expects, preserving full
chat functionality. Provider toggle is removed; defaults to google.

In non-chat apps, the bar submits to a new /api/triage endpoint that
calls generate() for a one-sentence response, shown inline. State
persists in localStorage across navigation.

Also removes App.app_bar_template field and all discovery code from
apps/__init__.py, makes body.has-app-bar class unconditional, and
adds chat-bar-* CSS classes for the new bar elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+287 -591
+1 -12
apps/__init__.py
··· 10 10 workspace.html # Required: Main template 11 11 routes.py # Optional: Flask blueprint (for custom routes beyond index) 12 12 background.html # Optional: Background service 13 - app_bar.html # Optional: Bottom bar 14 13 app.json # Optional: Metadata overrides 15 14 agents/ # Optional: Custom agents 16 15 tests/ # Optional: App-specific tests ··· 64 63 65 64 # Template paths (relative to Flask template root) 66 65 workspace_template: str = "" 67 - app_bar_template: Optional[str] = None 68 66 background_template: Optional[str] = None 69 67 70 68 # Facet configuration (optional, default {}) ··· 99 97 """Return path to workspace template.""" 100 98 return self.workspace_template 101 99 102 - def get_app_bar_template(self) -> Optional[str]: 103 - """Return path to custom app-bar template, or None.""" 104 - return self.app_bar_template 105 - 106 100 def get_background_template(self) -> Optional[str]: 107 101 """Return path to background service template, or None.""" 108 102 return self.background_template ··· 121 115 1. Check for workspace.html (required) 122 116 2. Load app.json if present (for icon, label overrides) 123 117 3. Import routes.py and get blueprint (optional - for custom routes) 124 - 4. Check for background.html, app_bar.html (optional) 118 + 4. Check for background.html (optional) 125 119 """ 126 120 apps_dir = Path(__file__).parent 127 121 ··· 236 230 if (app_path / "background.html").exists(): 237 231 background_template = f"{app_name}/background.html" 238 232 239 - app_bar_template = None 240 - if (app_path / "app_bar.html").exists(): 241 - app_bar_template = f"{app_name}/app_bar.html" 242 - 243 233 return App( 244 234 name=app_name, 245 235 icon=icon, 246 236 label=label, 247 237 blueprint=blueprint, 248 238 workspace_template=workspace_template, 249 - app_bar_template=app_bar_template, 250 239 background_template=background_template, 251 240 facets_config=facets_config, 252 241 date_nav=date_nav,
-87
apps/chat/app_bar.html
··· 1 - <style> 2 - .chat-message-input { 3 - flex: 1; 4 - resize: none; 5 - border: 1px solid #ddd; 6 - border-radius: 20px; 7 - padding: 0.75em 1em; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - max-height: 120px; 12 - min-height: 42px; 13 - } 14 - 15 - .chat-message-input:focus { 16 - outline: none; 17 - border-color: #667eea; 18 - } 19 - 20 - .chat-provider-toggle { 21 - margin-left: auto; 22 - padding: 0.75em 1em; 23 - background: transparent; 24 - border: 1px solid #ddd; 25 - border-radius: 20px; 26 - cursor: pointer; 27 - font-weight: 500; 28 - font-size: 14px; 29 - transition: all 0.2s; 30 - white-space: nowrap; 31 - color: #666; 32 - } 33 - 34 - .chat-provider-toggle:hover { 35 - background: #f5f5f5; 36 - border-color: #999; 37 - color: #333; 38 - } 39 - 40 - .chat-provider-toggle[data-provider="anthropic"] { 41 - color: #764ba2; 42 - border-color: #764ba2; 43 - } 44 - 45 - .chat-provider-toggle[data-provider="google"] { 46 - color: #4285f4; 47 - border-color: #4285f4; 48 - } 49 - 50 - .chat-provider-toggle[data-provider="openai"] { 51 - color: #10a37f; 52 - border-color: #10a37f; 53 - } 54 - 55 - </style> 56 - 57 - <!-- Use display:contents to make form invisible to flexbox --> 58 - <form id="chatInputForm" style="display: contents;"> 59 - <textarea id="chatMessageInput" class="chat-message-input" rows="1" placeholder="Send a message..."></textarea> 60 - <button type="button" id="chatProviderToggle" class="chat-provider-toggle" data-provider="google" title="Click to change provider"> 61 - Gemini 62 - </button> 63 - </form> 64 - 65 - <script> 66 - (function() { 67 - const input = document.getElementById('chatMessageInput'); 68 - 69 - if (!input) { 70 - console.error('Chat input not found'); 71 - return; 72 - } 73 - 74 - // Auto-resize textarea 75 - input.addEventListener('input', () => { 76 - input.style.height = 'auto'; 77 - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 78 - }); 79 - 80 - // Focus input if nothing else is focused 81 - setTimeout(() => { 82 - if (document.activeElement === document.body) { 83 - input.focus(); 84 - } 85 - }, 100); 86 - })(); 87 - </script>
+15 -10
apps/chat/workspace.html
··· 1020 1020 * @param {string} provider - Provider key (anthropic, google, openai) 1021 1021 */ 1022 1022 function setProvider(provider) { 1023 - if (!provider || !providers[provider] || !providerToggle) return; 1023 + if (!provider || !providers[provider]) return; 1024 1024 currentProvider = provider; 1025 - providerToggle.dataset.provider = provider; 1026 - providerToggle.textContent = providers[provider].label; 1025 + if (providerToggle) { 1026 + providerToggle.dataset.provider = provider; 1027 + providerToggle.textContent = providers[provider].label; 1028 + } 1027 1029 } 1028 1030 1029 1031 function initAppBarControls() { ··· 1031 1033 input = document.getElementById('chatMessageInput'); 1032 1034 providerToggle = document.getElementById('chatProviderToggle'); 1033 1035 1034 - if (!form || !input || !providerToggle) { 1036 + if (!form || !input) { 1035 1037 console.error('Chat controls not found'); 1036 1038 return; 1037 1039 } 1038 1040 1039 - currentProvider = providerToggle.dataset.provider; 1041 + // Provider toggle is optional (removed from universal bar) 1042 + currentProvider = providerToggle ? providerToggle.dataset.provider : 'google'; 1040 1043 1041 - providerToggle.addEventListener('click', () => { 1042 - currentProvider = providers[currentProvider].next; 1043 - providerToggle.dataset.provider = currentProvider; 1044 - providerToggle.textContent = providers[currentProvider].label; 1045 - }); 1044 + if (providerToggle) { 1045 + providerToggle.addEventListener('click', () => { 1046 + currentProvider = providers[currentProvider].next; 1047 + providerToggle.dataset.provider = currentProvider; 1048 + providerToggle.textContent = providers[currentProvider].label; 1049 + }); 1050 + } 1046 1051 1047 1052 setupFormHandlers(); 1048 1053 input.focus();
-99
apps/dev/app_bar.html
··· 1 - <style> 2 - .dev-facet-display { 3 - display: inline-flex; 4 - align-items: center; 5 - gap: 8px; 6 - padding: 8px 16px; 7 - background: #f9fafb; 8 - border: 1px solid #e5e7eb; 9 - border-radius: 8px; 10 - font-size: 14px; 11 - } 12 - 13 - .dev-facet-display.has-facet { 14 - background: var(--facet-bg, #f9fafb); 15 - border-color: var(--facet-color, #e5e7eb); 16 - } 17 - 18 - .dev-facet-display strong { 19 - font-weight: 500; 20 - color: #6b7280; 21 - } 22 - 23 - .dev-facet-display small { 24 - font-size: 11px; 25 - text-transform: uppercase; 26 - letter-spacing: 0.05em; 27 - color: #9ca3af; 28 - margin-left: 4px; 29 - } 30 - 31 - .dev-facet-all { 32 - color: #667eea; 33 - font-weight: 600; 34 - } 35 - </style> 36 - 37 - <div class="dev-facet-display{% if selected_facet %} has-facet{% endif %}" id="dev-facet-display"> 38 - <strong>Facet:</strong> 39 - <span id="dev-facet-value"> 40 - {% if selected_facet %} 41 - {% set selected_facet_data = facets|selectattr("name", "equalto", selected_facet)|first %} 42 - {% if selected_facet_data %} 43 - {{ selected_facet_data.emoji }} {{ selected_facet_data.title }} 44 - <small>specific</small> 45 - {% else %} 46 - {{ selected_facet }} 47 - <small>specific</small> 48 - {% endif %} 49 - {% else %} 50 - <strong class="dev-facet-all">All Facets</strong> 51 - <small>all-facet</small> 52 - {% endif %} 53 - </span> 54 - </div> 55 - 56 - <script> 57 - (function() { 58 - // Listen for facet.switch events 59 - window.addEventListener('facet.switch', (e) => { 60 - updateFacetDisplay(e.detail.facet, e.detail.facetData); 61 - }); 62 - 63 - function updateFacetDisplay(facetName, facetData) { 64 - const display = document.getElementById('dev-facet-display'); 65 - const valueEl = document.getElementById('dev-facet-value'); 66 - 67 - if (!display || !valueEl) return; 68 - 69 - if (!facetName) { 70 - // All-facet mode 71 - display.classList.remove('has-facet'); 72 - valueEl.innerHTML = ` 73 - <strong class="dev-facet-all">All Facets</strong> 74 - <small>all-facet</small> 75 - `; 76 - return; 77 - } 78 - 79 - // Use provided facetData or fallback to window.facetsData 80 - if (!facetData) { 81 - facetData = window.facetsData?.find(f => f.name === facetName); 82 - } 83 - 84 - display.classList.add('has-facet'); 85 - 86 - if (facetData) { 87 - valueEl.innerHTML = ` 88 - ${facetData.emoji} ${facetData.title} 89 - <small>specific</small> 90 - `; 91 - } else { 92 - valueEl.innerHTML = ` 93 - ${facetName} 94 - <small>specific</small> 95 - `; 96 - } 97 - } 98 - })(); 99 - </script>
-80
apps/entities/app_bar.html
··· 1 - <style> 2 - .entity-add-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .entity-add-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #667eea); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(102, 126, 234, 0.1)); 18 - } 19 - 20 - .entity-add-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - 24 - .entity-add-input:disabled { 25 - background: #f3f4f6; 26 - color: #9ca3af; 27 - cursor: not-allowed; 28 - opacity: 0.7; 29 - } 30 - 31 - .entity-add-input:disabled::placeholder { 32 - color: #d1d5db; 33 - } 34 - </style> 35 - 36 - <form id="entity-add-form" style="display: contents;"> 37 - <input 38 - type="text" 39 - id="entity-assist-input" 40 - class="entity-add-input" 41 - placeholder="Add entity with AI (e.g., 'OpenAI', 'Claude')..." 42 - autocomplete="off" 43 - > 44 - </form> 45 - 46 - <script> 47 - (function() { 48 - const input = document.getElementById('entity-assist-input'); 49 - if (!input) return; 50 - 51 - // Check if facet is selected and update input state 52 - function updateInputState() { 53 - const facet = window.selectedFacet; 54 - if (!facet) { 55 - input.disabled = true; 56 - input.placeholder = 'Select a facet to add entities'; 57 - } else { 58 - input.disabled = false; 59 - input.placeholder = "Add entity with AI (e.g., 'OpenAI', 'Claude')..."; 60 - } 61 - } 62 - 63 - // Initial state - wait for DOM to ensure window.selectedFacet is set 64 - if (document.readyState === 'loading') { 65 - document.addEventListener('DOMContentLoaded', updateInputState); 66 - } else { 67 - updateInputState(); 68 - } 69 - 70 - // Listen for facet changes 71 - window.addEventListener('facet.switch', updateInputState); 72 - 73 - // Focus input if nothing else is focused 74 - setTimeout(() => { 75 - if (document.activeElement === document.body && !input.disabled) { 76 - input.focus(); 77 - } 78 - }, 100); 79 - })(); 80 - </script>
-62
apps/search/app_bar.html
··· 1 - <style> 2 - .search-bar-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .search-bar-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #2563eb); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(37, 99, 235, 0.1)); 18 - } 19 - 20 - .search-bar-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - </style> 24 - 25 - <!-- Use display:contents to make form invisible to flexbox --> 26 - <form id="search-bar-form" style="display: contents;"> 27 - <input 28 - type="text" 29 - id="search-bar-input" 30 - class="search-bar-input" 31 - placeholder="Search your journal..." 32 - autocomplete="off" 33 - > 34 - </form> 35 - 36 - <script> 37 - (function() { 38 - const form = document.getElementById('search-bar-form'); 39 - const input = document.getElementById('search-bar-input'); 40 - if (!form || !input) return; 41 - 42 - // Focus input on page load if nothing else is focused 43 - setTimeout(() => { 44 - if (document.activeElement === document.body) { 45 - input.focus(); 46 - } 47 - }, 100); 48 - 49 - // Handle form submission 50 - form.addEventListener('submit', (e) => { 51 - e.preventDefault(); 52 - window.dispatchEvent(new CustomEvent('search.submit', { 53 - detail: { query: input.value.trim() } 54 - })); 55 - }); 56 - 57 - // Listen for external query updates (hash changes) 58 - window.addEventListener('search.queryUpdate', (e) => { 59 - input.value = e.detail.query || ''; 60 - }); 61 - })(); 62 - </script>
-231
apps/todos/app_bar.html
··· 1 - <style> 2 - .todo-add-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .todo-add-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #2563eb); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(37, 99, 235, 0.1)); 18 - } 19 - 20 - .todo-add-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - 24 - .todo-add-input:disabled { 25 - background: #f3f4f6; 26 - color: #9ca3af; 27 - cursor: not-allowed; 28 - opacity: 0.7; 29 - } 30 - 31 - .todo-add-input:disabled::placeholder { 32 - color: #d1d5db; 33 - } 34 - 35 - .appbar-generate-btn { 36 - display: none; 37 - align-items: center; 38 - justify-content: center; 39 - width: 2.5rem; 40 - height: 2.5rem; 41 - padding: 0; 42 - border: none; 43 - border-radius: 50%; 44 - background: transparent; 45 - font-size: 1.25rem; 46 - cursor: pointer; 47 - transition: transform 0.2s, background 0.15s; 48 - } 49 - 50 - .appbar-generate-btn:hover { 51 - background: rgba(0, 0, 0, 0.05); 52 - transform: scale(1.1); 53 - } 54 - 55 - .appbar-generate-btn.generating { 56 - animation: appbar-spin 2s linear infinite; 57 - } 58 - 59 - @keyframes appbar-spin { 60 - from { transform: rotate(0deg); } 61 - to { transform: rotate(360deg); } 62 - } 63 - 64 - .appbar-generate-btn.visible { 65 - display: inline-flex; 66 - } 67 - </style> 68 - 69 - <!-- Use display:contents to make form invisible to flexbox --> 70 - <form method="post" style="display: contents;"> 71 - <input type="hidden" name="action" value="add"> 72 - <input 73 - type="text" 74 - name="text" 75 - id="todo-add-input" 76 - class="todo-add-input" 77 - placeholder="{% if selected_facet %}Add a todo to {{ facets|selectattr('name', 'equalto', selected_facet)|map(attribute='title')|first|default(selected_facet) }}...{% else %}Add a todo... (use #facet){% endif %}" 78 - autocomplete="off" 79 - > 80 - </form> 81 - 82 - <!-- Generate button for single-facet mode --> 83 - <button 84 - type="button" 85 - class="appbar-generate-btn{% if selected_facet %} visible{% endif %}" 86 - id="appbar-generate-btn" 87 - data-day="{{ day }}" 88 - title="Generate weekly todos with AI" 89 - >✨</button> 90 - 91 - <script> 92 - (function() { 93 - const input = document.getElementById('todo-add-input'); 94 - const form = input?.closest('form'); 95 - if (!input || !form || input.disabled) return; 96 - 97 - function updatePlaceholder(facetName) { 98 - if (facetName) { 99 - const facetData = window.facetsData?.find(f => f.name === facetName); 100 - const facetTitle = facetData?.title || facetName; 101 - input.placeholder = `Add a todo to ${facetTitle}...`; 102 - } else { 103 - input.placeholder = 'Add a todo... (use #facet)'; 104 - } 105 - } 106 - 107 - // Listen for facet selection changes 108 - window.addEventListener('facet.switch', (e) => { 109 - updatePlaceholder(e.detail.facet); 110 - }); 111 - 112 - // Handle form submission via AJAX 113 - form.addEventListener('submit', async (e) => { 114 - e.preventDefault(); 115 - 116 - const text = input.value.trim(); 117 - if (!text) { 118 - input.focus(); 119 - return; 120 - } 121 - 122 - // Capture form data BEFORE disabling (disabled inputs are excluded from FormData) 123 - const formData = new FormData(form); 124 - input.disabled = true; 125 - 126 - try { 127 - const response = await fetch(window.location.href, { 128 - method: 'POST', 129 - headers: { 'X-Requested-With': 'XMLHttpRequest' }, 130 - body: formData 131 - }); 132 - 133 - const data = await response.json(); 134 - 135 - if (response.ok && data.status === 'ok') { 136 - input.value = ''; 137 - window.dispatchEvent(new CustomEvent('todo.added', { detail: data.todo })); 138 - } else { 139 - alert(data.message || 'Failed to add todo'); 140 - } 141 - } catch (error) { 142 - alert('Network error - please try again'); 143 - } finally { 144 - input.disabled = false; 145 - input.focus(); 146 - } 147 - }); 148 - })(); 149 - </script> 150 - 151 - <script> 152 - (function() { 153 - const generateBtn = document.getElementById('appbar-generate-btn'); 154 - if (!generateBtn) return; 155 - 156 - const day = generateBtn.dataset.day; 157 - let currentFacet = window.selectedFacet; 158 - let agentId = null; 159 - 160 - // Show/hide button based on facet selection 161 - function updateVisibility(facetName) { 162 - currentFacet = facetName; 163 - if (facetName) { 164 - generateBtn.classList.add('visible'); 165 - // Reset state when switching facets 166 - resetButton(); 167 - } else { 168 - generateBtn.classList.remove('visible'); 169 - } 170 - } 171 - 172 - function resetButton() { 173 - generateBtn.textContent = '✨'; 174 - generateBtn.classList.remove('generating', 'completed'); 175 - agentId = null; 176 - } 177 - 178 - // Listen for facet selection changes 179 - window.addEventListener('facet.switch', (e) => { 180 - updateVisibility(e.detail.facet); 181 - }); 182 - 183 - // Handle click 184 - generateBtn.addEventListener('click', async () => { 185 - if (!currentFacet) return; 186 - 187 - // If completed, clicking does nothing 188 - if (generateBtn.classList.contains('completed')) return; 189 - 190 - // If generating, could navigate to agent (future enhancement) 191 - if (generateBtn.classList.contains('generating')) return; 192 - 193 - // Start generation 194 - generateBtn.textContent = '⏳'; 195 - generateBtn.classList.add('generating'); 196 - 197 - try { 198 - const response = await fetch(`/app/todos/${day}/generate-weekly/${currentFacet}`, { 199 - method: 'POST' 200 - }); 201 - 202 - if (!response.ok) { 203 - throw new Error('Failed to start generation'); 204 - } 205 - 206 - const data = await response.json(); 207 - agentId = data.agent_id; 208 - 209 - } catch (error) { 210 - alert('Failed to start todo generation'); 211 - resetButton(); 212 - } 213 - }); 214 - 215 - // Listen for cortex events (agent completion) 216 - if (window.appEvents) { 217 - window.appEvents.listen('cortex', msg => { 218 - if (!agentId || msg.agent_id !== agentId) return; 219 - 220 - if (msg.event === 'finish') { 221 - generateBtn.textContent = '✅'; 222 - generateBtn.classList.remove('generating'); 223 - generateBtn.classList.add('completed'); 224 - } else if (msg.event === 'error') { 225 - alert('Todo generation failed: ' + (msg.error || 'Unknown error')); 226 - resetButton(); 227 - } 228 - }); 229 - } 230 - })(); 231 - </script>
+4
convey/__init__.py
··· 19 19 from .bridge import emit, register_websocket 20 20 from .config import bp as config_bp 21 21 from .root import bp as root_bp 22 + from .triage import bp as triage_bp 22 23 23 24 __all__ = [ 24 25 "create_app", ··· 53 54 54 55 # Register config API blueprint 55 56 app.register_blueprint(config_bp) 57 + 58 + # Register triage API blueprint (universal chat bar) 59 + app.register_blueprint(triage_bp) 56 60 57 61 # Initialize and register app system 58 62 registry = AppRegistry()
+81 -4
convey/static/app.css
··· 106 106 .workspace { 107 107 margin-top: var(--facet-bar-height); 108 108 margin-left: var(--menu-bar-width-minimal); 109 - } 110 - 111 - /* Add bottom margin when app-bar is present */ 112 - body.has-app-bar .workspace { 113 109 margin-bottom: var(--app-bar-height); 114 110 } 115 111 ··· 1026 1022 pointer-events: none; 1027 1023 z-index: -1; 1028 1024 border-radius: 12px; 1025 + } 1026 + 1027 + /* Chat Bar Elements */ 1028 + .chat-bar-input { 1029 + flex: 1; 1030 + resize: none; 1031 + border: 1px solid #ddd; 1032 + border-radius: 20px; 1033 + padding: 0.75em 1em; 1034 + font-family: inherit; 1035 + font-size: 14px; 1036 + line-height: 1.5; 1037 + max-height: 120px; 1038 + min-height: 42px; 1039 + background: transparent; 1040 + position: relative; 1041 + z-index: 1; 1042 + } 1043 + 1044 + .chat-bar-input:focus { 1045 + outline: none; 1046 + border-color: var(--facet-color, #667eea); 1047 + } 1048 + 1049 + .chat-bar-input::placeholder { 1050 + color: #999; 1051 + } 1052 + 1053 + .chat-bar-send { 1054 + width: 36px; 1055 + height: 36px; 1056 + border-radius: 50%; 1057 + border: none; 1058 + background: var(--facet-color, #667eea); 1059 + color: white; 1060 + font-size: 18px; 1061 + cursor: pointer; 1062 + display: flex; 1063 + align-items: center; 1064 + justify-content: center; 1065 + position: relative; 1066 + z-index: 1; 1067 + flex-shrink: 0; 1068 + } 1069 + 1070 + .chat-bar-send:hover { 1071 + opacity: 0.85; 1072 + } 1073 + 1074 + .chat-bar-thinking { 1075 + display: flex; 1076 + align-items: center; 1077 + gap: 0.5em; 1078 + color: #999; 1079 + font-size: 13px; 1080 + position: relative; 1081 + z-index: 1; 1082 + } 1083 + 1084 + .chat-bar-thinking-dot { 1085 + width: 8px; 1086 + height: 8px; 1087 + background: var(--facet-color, #667eea); 1088 + border-radius: 50%; 1089 + animation: chat-bar-pulse 1s ease-in-out infinite; 1090 + } 1091 + 1092 + @keyframes chat-bar-pulse { 1093 + 0%, 100% { opacity: 0.3; } 1094 + 50% { opacity: 1; } 1095 + } 1096 + 1097 + .chat-bar-response { 1098 + flex: 1; 1099 + font-size: 13px; 1100 + color: #666; 1101 + white-space: nowrap; 1102 + overflow: hidden; 1103 + text-overflow: ellipsis; 1104 + position: relative; 1105 + z-index: 1; 1029 1106 } 1030 1107 1031 1108 /* Error log display */
+128 -6
convey/templates/app.html
··· 34 34 {% endif %} 35 35 </head> 36 36 {% set body_classes = [] %} 37 - {% if app_registry.apps[app].get_app_bar_template() %}{% set _ = body_classes.append('has-app-bar') %}{% endif %} 37 + {% set _ = body_classes.append('has-app-bar') %} 38 38 {% if app_registry.apps[app].date_nav_enabled() and day %}{% set _ = body_classes.append('has-date-nav') %}{% endif %} 39 39 <body{% if body_classes %} class="{{ body_classes|join(' ') }}"{% endif %}> 40 40 <!-- Corner Tags (screen region detection) --> ··· 66 66 <!-- Notification Center --> 67 67 <div class="notification-center" id="notification-center"></div> 68 68 69 - <!-- App Bar (bottom) - only render if app provides template --> 70 - {% set app_bar_template = app_registry.apps[app].get_app_bar_template() %} 71 - {% if app_bar_template %} 69 + <!-- Chat Bar (universal) --> 72 70 <div class="app-bar"> 73 - {% include app_bar_template %} 71 + <form id="chatInputForm" style="display: contents;"> 72 + <textarea id="chatMessageInput" class="chat-bar-input" rows="1" placeholder="Send a message..."></textarea> 73 + <button type="submit" class="chat-bar-send" title="Send" style="display: none;">↑</button> 74 + </form> 75 + <div class="chat-bar-thinking" style="display: none;"> 76 + <span class="chat-bar-thinking-dot"></span> 77 + Thinking… 78 + </div> 79 + <div class="chat-bar-response" style="display: none;"></div> 74 80 </div> 75 - {% endif %} 76 81 77 82 <!-- Main Content --> 78 83 <div class="workspace"> ··· 81 86 82 87 <!-- App JavaScript (includes AppServices framework) --> 83 88 <script src="{{ url_for('root.static', filename='app.js') }}"></script> 89 + 90 + <script> 91 + (function() { 92 + const chatBarInput = document.getElementById('chatMessageInput'); 93 + const chatBarForm = document.getElementById('chatInputForm'); 94 + const chatBarThinking = document.querySelector('.chat-bar-thinking'); 95 + const chatBarResponse = document.querySelector('.chat-bar-response'); 96 + const chatBarSend = document.querySelector('.chat-bar-send'); 97 + const isChatApp = window.location.pathname.startsWith('/app/chat'); 98 + const STORAGE_KEY = 'solstone:chatBarState'; 99 + 100 + if (!chatBarInput || !chatBarForm) return; 101 + 102 + // --- Auto-resize textarea --- 103 + chatBarInput.addEventListener('input', () => { 104 + chatBarInput.style.height = 'auto'; 105 + chatBarInput.style.height = Math.min(chatBarInput.scrollHeight, 120) + 'px'; 106 + // Show/hide send button 107 + if (chatBarSend) { 108 + chatBarSend.style.display = chatBarInput.value.trim() ? '' : 'none'; 109 + } 110 + }); 111 + 112 + // --- Focus on load (only if nothing else focused) --- 113 + setTimeout(() => { 114 + if (document.activeElement === document.body) { 115 + chatBarInput.focus(); 116 + } 117 + }, 100); 118 + 119 + // --- Restore state from localStorage --- 120 + try { 121 + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); 122 + if (saved && saved.response && !isChatApp && chatBarResponse) { 123 + chatBarResponse.textContent = saved.response; 124 + chatBarResponse.style.display = ''; 125 + } 126 + } catch {} 127 + 128 + // --- Non-chat app: triage submit --- 129 + if (!isChatApp) { 130 + // Enter to submit, shift+enter for newline 131 + chatBarInput.addEventListener('keydown', e => { 132 + if (e.key === 'Enter' && !e.shiftKey) { 133 + e.preventDefault(); 134 + chatBarForm.requestSubmit(); 135 + } 136 + }); 137 + 138 + chatBarForm.onsubmit = async (e) => { 139 + e.preventDefault(); 140 + const text = chatBarInput.value.trim(); 141 + if (!text) return; 142 + 143 + // Show thinking state 144 + chatBarInput.value = ''; 145 + chatBarInput.style.height = ''; 146 + if (chatBarSend) { 147 + chatBarSend.style.display = 'none'; 148 + } 149 + chatBarInput.disabled = true; 150 + if (chatBarThinking) { 151 + chatBarThinking.style.display = ''; 152 + } 153 + if (chatBarResponse) { 154 + chatBarResponse.style.display = 'none'; 155 + } 156 + 157 + try { 158 + const r = await fetch('/api/triage', { 159 + method: 'POST', 160 + headers: { 'Content-Type': 'application/json' }, 161 + body: JSON.stringify({ 162 + message: text, 163 + app: '{{ app }}', 164 + path: window.location.pathname, 165 + facet: window.selectedFacet || null 166 + }) 167 + }); 168 + 169 + if (r.ok) { 170 + const data = await r.json(); 171 + if (chatBarResponse) { 172 + chatBarResponse.textContent = data.response || ''; 173 + chatBarResponse.style.display = data.response ? '' : 'none'; 174 + } 175 + 176 + // Save to localStorage 177 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 178 + message: text, 179 + response: data.response || '', 180 + timestamp: Date.now(), 181 + app: '{{ app }}', 182 + path: window.location.pathname 183 + })); 184 + } else if (chatBarResponse) { 185 + chatBarResponse.textContent = 'Something went wrong. Try again.'; 186 + chatBarResponse.style.display = ''; 187 + } 188 + } catch (err) { 189 + if (chatBarResponse) { 190 + chatBarResponse.textContent = 'Connection error. Try again.'; 191 + chatBarResponse.style.display = ''; 192 + } 193 + } finally { 194 + if (chatBarThinking) { 195 + chatBarThinking.style.display = 'none'; 196 + } 197 + chatBarInput.disabled = false; 198 + chatBarInput.focus(); 199 + } 200 + }; 201 + } 202 + // In chat app: workspace's setupFormHandlers() takes over form submission. 203 + // The bar just provides the DOM elements (chatInputForm, chatMessageInput). 204 + })(); 205 + </script> 84 206 85 207 <!-- Load all app background scripts --> 86 208 {% for app_name, app_instance in app_registry.apps.items() %}
+58
convey/triage.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Triage endpoint for universal chat bar queries from non-chat apps.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + from typing import Any 10 + 11 + from flask import Blueprint, request 12 + 13 + from convey.utils import error_response 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + bp = Blueprint("triage", __name__, url_prefix="/api/triage") 18 + 19 + 20 + @bp.route("", methods=["POST"]) 21 + def triage() -> Any: 22 + """Accept a message from the universal chat bar and return a response. 23 + 24 + Expects JSON: {message, app, path, facet} 25 + Returns JSON: {response} 26 + """ 27 + payload = request.get_json(force=True) 28 + message = payload.get("message", "").strip() 29 + 30 + if not message: 31 + return error_response("message is required", 400) 32 + 33 + app_name = payload.get("app", "") 34 + path = payload.get("path", "") 35 + facet = payload.get("facet") 36 + 37 + try: 38 + from think.models import generate 39 + 40 + system_prompt = ( 41 + "You are a helpful assistant in solstone, a journaling toolkit. " 42 + "The user is asking from the app bar. " 43 + "Give a brief, one-sentence answer." 44 + ) 45 + 46 + ctx = f"App: {app_name}, Path: {path}" 47 + if facet: 48 + ctx += f", Facet: {facet}" 49 + 50 + full_prompt = f"[Context: {ctx}]\n\n{message}" 51 + 52 + response_text = generate( 53 + full_prompt, context="convey.triage", system_instruction=system_prompt 54 + ) 55 + return {"response": response_text} 56 + except Exception: 57 + logger.exception("Triage generation failed") 58 + return error_response("Failed to generate response", 500)