Fix conversation titles stuck on 'New Chat' and add loop timestamps to dashboard

- Move _maybe_set_title() to run at start of respond() generator (before streaming)
  so titles are set even if the generator is closed mid-stream by Gradio
- Refresh sidebar conv list on first streaming chunk for immediate title display
- Backfilled 34 existing conversation titles from their first user messages
- Add scheduler loop status cards (heartbeat, poll, clickup, folder_watch) to
  both System Health and Notifications tabs in the HTML dashboard
- Loop cards show relative time (e.g. "3m ago") with color-coded status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-23 10:54:43 -06:00
parent f8320a9fea
commit 603878e095
3 changed files with 153 additions and 6 deletions

View File

@ -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:

View File

@ -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

View File

@ -270,6 +270,15 @@
<div class="stats-row" id="health-stats"></div>
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#9881;</span> Scheduler Loops</h2>
</div>
<div class="health-grid" id="health-loop-grid">
<p style="color:var(--text-muted);">Loading loop status...</p>
</div>
</div>
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128190;</span> Disk Space</h2>
@ -297,6 +306,16 @@
<p class="page-header__date">Recent system notifications and events</p>
</div>
<!-- Scheduler Loop Status -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#9881;</span> Scheduler Loops</h2>
</div>
<div class="health-grid" id="loop-status-grid">
<p style="color:var(--text-muted);">Loading loop status...</p>
</div>
</div>
<div class="task-table-wrap" id="notifications-list">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
@ -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 = `
<div class="stat-card stat-card--${health.execution_brain ? 'green' : 'red'}">
@ -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 = '<p style="color:var(--text-muted);">Scheduler not available.</p>';
return;
}
let html = '';
for (const [key, ts] of Object.entries(data.loops)) {
const label = LOOP_LABELS[key] || key;
const { text, status } = formatLoopAgo(ts);
html += `<div class="health-card">
<div class="health-card__header">
<span class="health-card__title">${esc(label)}</span>
<span class="health-card__status health-card__status--${status}">${esc(text)}</span>
</div>
<div class="health-card__meta" style="margin-top:0.25rem;">
<span style="font-size:0.7rem;color:var(--text-muted);">${ts ? new Date(ts).toLocaleString('en-US', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit'}) : 'N/A'}</span>
</div>
</div>`;
}
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;