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
parent
f8320a9fea
commit
603878e095
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">⚙</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">💾</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">⚙</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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue