diff --git a/cheddahbot/agent.py b/cheddahbot/agent.py index 76d2c4e..a0f4173 100644 --- a/cheddahbot/agent.py +++ b/cheddahbot/agent.py @@ -114,6 +114,9 @@ class Agent: """Process user input and yield streaming response text.""" conv_id = self.ensure_conversation() + # Auto-title early so it's set even if the generator is closed mid-stream + self._maybe_set_title(conv_id, user_input) + # Store user message self.db.add_message(conv_id, "user", user_input) @@ -285,9 +288,6 @@ class Agent: else: yield "\n(Reached maximum tool iterations)" - # Auto-title the conversation from the first user message - self._maybe_set_title(conv_id, user_input) - # Check if memory flush is needed if self._memory: msg_count = self.db.count_messages(conv_id) @@ -295,9 +295,10 @@ class Agent: self._memory.auto_flush(conv_id) def _maybe_set_title(self, conv_id: str, user_input: str): - """Set conversation title from first user message if still untitled.""" + """Set conversation title from first user message if still 'New Chat'.""" try: - if self.db.count_messages(conv_id) > 3: + current_title = self.db.get_conversation_title(conv_id) + if current_title and current_title != "New Chat": return title = user_input.split("\n", 1)[0].strip() if len(title) > 50: diff --git a/cheddahbot/ui.py b/cheddahbot/ui.py index 955fb30..07a3c83 100644 --- a/cheddahbot/ui.py +++ b/cheddahbot/ui.py @@ -138,6 +138,7 @@ def create_ui( config: Config, llm: LLMAdapter, notification_bus: NotificationBus | None = None, + scheduler=None, ) -> gr.Blocks: """Build and return the Gradio app.""" @@ -220,6 +221,16 @@ def create_ui( elem_classes=["contain", "notification-banner"], ) + # -- System Loop Monitoring -- + with gr.Row(elem_classes=["contain"]): + loop_status = gr.Markdown( + value="*Recent System events* | System Loop: waiting for first run...", + ) + force_pulse_btn = gr.Button( + "Force Pulse", variant="secondary", size="sm", + scale=0, min_width=110, + ) + with gr.Row(elem_classes=["contain"]): model_dropdown = gr.Dropdown( choices=model_choices, @@ -361,6 +372,7 @@ def create_ui( try: response_text = "" assistant_added = False + title_refreshed = False for chunk in agent.respond(text, files=processed_files): response_text += chunk @@ -372,7 +384,16 @@ def create_ui( assistant_added = True else: chat_history[-1] = {"role": "assistant", "content": response_text} - yield chat_history, gr.update(value=None), gr.update() + + # Refresh conv list on first chunk so sidebar shows + # the updated title immediately (title is set at the + # start of agent.respond before streaming begins). + if not title_refreshed: + title_refreshed = True + convs = _refresh_conv_list(agent_name) + yield chat_history, gr.update(value=None), convs + else: + yield chat_history, gr.update(value=None), gr.update() # If no response came through, show a fallback if not response_text: @@ -455,8 +476,53 @@ def create_ui( banner = "\n\n".join(lines) return gr.update(value=banner, visible=True) + def poll_loop_status(): + """Poll scheduler loop timestamps and format for display.""" + if not scheduler: + return gr.update() + from datetime import UTC + from datetime import datetime as _dt + + ts = scheduler.get_loop_timestamps() + parts = [] + for name, iso_ts in ts.items(): + if iso_ts: + try: + dt = _dt.fromisoformat(iso_ts) + delta = _dt.now(UTC) - dt + secs = int(delta.total_seconds()) + if secs < 60: + ago = f"{secs}s ago" + elif secs < 3600: + ago = f"{secs // 60}m ago" + else: + ago = f"{secs // 3600}h {(secs % 3600) // 60}m ago" + parts.append(f"{name}: {ago}") + except Exception: + parts.append(f"{name}: {iso_ts}") + if parts: + return gr.update( + value=f"*Recent System events* | System Loop: {' | '.join(parts)}" + ) + return gr.update( + value="*Recent System events* | System Loop: waiting for first run..." + ) + + def on_force_pulse(): + if not scheduler: + return gr.update( + value="*Recent System events* | System Loop: scheduler not available" + ) + scheduler.force_heartbeat() + scheduler.force_poll() + return gr.update( + value="*Recent System events* | System Loop: **Force pulse sent!**" + ) + # -- Wire events -- + force_pulse_btn.click(on_force_pulse, None, [loop_status]) + model_dropdown.change(on_model_change, [model_dropdown], None) refresh_btn.click(on_refresh_models, None, [model_dropdown]) @@ -495,4 +561,9 @@ def create_ui( timer = gr.Timer(10) timer.tick(poll_notifications, None, [notification_display]) + # System loop status polling timer (every 30 seconds) + if scheduler: + loop_timer = gr.Timer(30) + loop_timer.tick(poll_loop_status, None, [loop_status]) + return app diff --git a/dashboard/index.html b/dashboard/index.html index 8e705f4..214a90f 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -270,6 +270,15 @@
+
+
+

Scheduler Loops

+
+
+

Loading loop status...

+
+
+

💾 Disk Space

@@ -297,6 +306,16 @@

Recent system notifications and events

+ +
+
+

Scheduler Loops

+
+
+

Loading loop status...

+
+
+

Loading...

@@ -883,6 +902,9 @@ async function loadHealth() { document.getElementById('health-subtitle').textContent = `Chat model: ${health.chat_model || 'unknown'} | Execution model: ${health.execution_model || 'unknown'}`; + // Loop status grid + renderLoopGrid('health-loop-grid'); + // Stats let statsHtml = `
@@ -959,9 +981,62 @@ async function loadAgents() { grid.innerHTML = html; } +// --- Loop Status --- + +function formatLoopAgo(isoTs) { + if (!isoTs) return { text: 'Never run', status: 'warn' }; + try { + const dt = new Date(isoTs); + const secs = Math.floor((Date.now() - dt.getTime()) / 1000); + let ago; + if (secs < 60) ago = `${secs}s ago`; + else if (secs < 3600) ago = `${Math.floor(secs / 60)}m ago`; + else ago = `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; + const status = secs < 600 ? 'ok' : secs < 3600 ? 'warn' : 'error'; + return { text: ago, status }; + } catch { + return { text: isoTs, status: 'warn' }; + } +} + +const LOOP_LABELS = { + heartbeat: 'Heartbeat', + poll: 'Task Poll', + clickup: 'ClickUp', + folder_watch: 'Folder Watcher', +}; + +async function renderLoopGrid(containerId) { + const data = await fetchJSON('/system/loops'); + const container = document.getElementById(containerId); + if (!data || !data.loops) { + container.innerHTML = '

Scheduler not available.

'; + return; + } + + let html = ''; + for (const [key, ts] of Object.entries(data.loops)) { + const label = LOOP_LABELS[key] || key; + const { text, status } = formatLoopAgo(ts); + html += `
+
+ ${esc(label)} + ${esc(text)} +
+
+ ${ts ? new Date(ts).toLocaleString('en-US', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit'}) : 'N/A'} +
+
`; + } + container.innerHTML = html; +} + // --- Notifications Tab --- async function loadNotifications() { + // Load loop status grid in notifications tab + await renderLoopGrid('loop-status-grid'); + const data = await fetchJSON('/system/notifications'); if (!data) return;