diff --git a/cheddahbot/__main__.py b/cheddahbot/__main__.py
index dcf7b4d..1144a23 100644
--- a/cheddahbot/__main__.py
+++ b/cheddahbot/__main__.py
@@ -131,7 +131,7 @@ def main():
log.warning("Scheduler not available: %s", e)
log.info("Launching Gradio UI on %s:%s...", config.host, config.port)
- app = create_ui(default_agent, config, default_llm, notification_bus=notification_bus)
+ app = create_ui(registry, config, default_llm, notification_bus=notification_bus)
app.launch(
server_name=config.host,
server_port=config.port,
diff --git a/cheddahbot/agent.py b/cheddahbot/agent.py
index 73aae28..58b1217 100644
--- a/cheddahbot/agent.py
+++ b/cheddahbot/agent.py
@@ -95,14 +95,19 @@ class Agent:
def ensure_conversation(self) -> str:
if not self.conv_id:
self.conv_id = uuid.uuid4().hex[:12]
- self.db.create_conversation(self.conv_id)
+ self.db.create_conversation(self.conv_id, agent_name=self.name)
return self.conv_id
def new_conversation(self) -> str:
self.conv_id = uuid.uuid4().hex[:12]
- self.db.create_conversation(self.conv_id)
+ self.db.create_conversation(self.conv_id, agent_name=self.name)
return self.conv_id
+ def load_conversation(self, conv_id: str) -> list[dict]:
+ """Load an existing conversation by ID. Returns message list."""
+ self.conv_id = conv_id
+ return self.db.get_messages(conv_id)
+
def respond(self, user_input: str, files: list | None = None) -> Generator[str, None, None]:
"""Process user input and yield streaming response text."""
conv_id = self.ensure_conversation()
diff --git a/cheddahbot/db.py b/cheddahbot/db.py
index 6703f0b..8d63eca 100644
--- a/cheddahbot/db.py
+++ b/cheddahbot/db.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import contextlib
import json
import sqlite3
import threading
@@ -72,24 +73,42 @@ class Database:
created_at TEXT NOT NULL
);
""")
+ # Migration: add agent_name column to conversations (idempotent)
+ with contextlib.suppress(sqlite3.OperationalError):
+ self._conn.execute(
+ "ALTER TABLE conversations ADD COLUMN agent_name TEXT DEFAULT 'default'"
+ )
self._conn.commit()
# -- Conversations --
- def create_conversation(self, conv_id: str, title: str = "New Chat") -> str:
+ def create_conversation(
+ self, conv_id: str, title: str = "New Chat", agent_name: str = "default"
+ ) -> str:
now = _now()
self._conn.execute(
- "INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
- (conv_id, title, now, now),
+ "INSERT INTO conversations (id, title, created_at, updated_at, agent_name)"
+ " VALUES (?, ?, ?, ?, ?)",
+ (conv_id, title, now, now, agent_name),
)
self._conn.commit()
return conv_id
- def list_conversations(self, limit: int = 50) -> list[dict]:
- rows = self._conn.execute(
- "SELECT id, title, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ?",
- (limit,),
- ).fetchall()
+ def list_conversations(
+ self, limit: int = 50, agent_name: str | None = None
+ ) -> list[dict]:
+ if agent_name:
+ rows = self._conn.execute(
+ "SELECT id, title, updated_at, agent_name FROM conversations"
+ " WHERE agent_name = ? ORDER BY updated_at DESC LIMIT ?",
+ (agent_name, limit),
+ ).fetchall()
+ else:
+ rows = self._conn.execute(
+ "SELECT id, title, updated_at, agent_name FROM conversations"
+ " ORDER BY updated_at DESC LIMIT ?",
+ (limit,),
+ ).fetchall()
return [dict(r) for r in rows]
# -- Messages --
diff --git a/cheddahbot/ui.py b/cheddahbot/ui.py
index 49df93e..69c8482 100644
--- a/cheddahbot/ui.py
+++ b/cheddahbot/ui.py
@@ -9,14 +9,32 @@ from typing import TYPE_CHECKING
import gradio as gr
if TYPE_CHECKING:
- from .agent import Agent
+ from .agent_registry import AgentRegistry
from .config import Config
from .llm import LLMAdapter
from .notifications import NotificationBus
log = logging.getLogger(__name__)
-_HEAD = ''
+_HEAD = """
+
+
+"""
_CSS = """
footer { display: none !important; }
@@ -29,6 +47,34 @@ footer { display: none !important; }
font-size: 0.9em;
}
+/* Sidebar conversation buttons */
+.conv-btn {
+ text-align: left !important;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 8px 12px !important;
+ margin: 2px 0 !important;
+ font-size: 0.85em !important;
+ min-height: 44px !important;
+ width: 100% !important;
+}
+.conv-btn.active {
+ border-color: var(--color-accent) !important;
+ background: var(--color-accent-soft) !important;
+}
+
+/* Agent radio: larger touch targets */
+.agent-radio .wrap {
+ gap: 4px !important;
+}
+.agent-radio .wrap label {
+ min-height: 44px !important;
+ padding: 8px 12px !important;
+ display: flex;
+ align-items: center;
+}
+
/* Mobile optimizations */
@media (max-width: 768px) {
.gradio-container { padding: 4px !important; }
@@ -40,7 +86,7 @@ footer { display: none !important; }
.chatbot {
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
- height: calc(100dvh - 220px) !important;
+ height: calc(100dvh - 200px) !important;
max-height: none !important;
}
@@ -69,12 +115,29 @@ footer { display: none !important; }
/* Reduce model dropdown row padding */
.contain .gr-row { gap: 4px !important; }
+
+ /* Prevent horizontal overflow on narrow viewports */
+ .gradio-container { overflow-x: hidden !important; max-width: 100vw !important; }
}
"""
+def _messages_to_chatbot(messages: list[dict]) -> list[dict]:
+ """Convert DB messages to Gradio chatbot format, skipping tool messages."""
+ chatbot_msgs = []
+ for msg in messages:
+ role = msg.get("role", "")
+ content = msg.get("content", "")
+ if role in ("user", "assistant") and content:
+ chatbot_msgs.append({"role": role, "content": content})
+ return chatbot_msgs
+
+
def create_ui(
- agent: Agent, config: Config, llm: LLMAdapter, notification_bus: NotificationBus | None = None
+ registry: AgentRegistry,
+ config: Config,
+ llm: LLMAdapter,
+ notification_bus: NotificationBus | None = None,
) -> gr.Blocks:
"""Build and return the Gradio app."""
@@ -85,7 +148,63 @@ def create_ui(
exec_status = "available" if llm.is_execution_brain_available() else "unavailable"
clickup_status = "enabled" if config.clickup.enabled else "disabled"
+ # Build agent name → display_name mapping
+ agent_names = registry.list_agents()
+ agent_display_map = {} # display_name → internal name
+ agent_choices = [] # (display_name, internal_name) for Radio
+ for name in agent_names:
+ agent = registry.get(name)
+ display = agent.agent_config.display_name if agent else name
+ agent_display_map[display] = name
+ agent_choices.append((display, name))
+
+ default_agent_name = registry.default_name
+
with gr.Blocks(title="CheddahBot", fill_width=True, css=_CSS, head=_HEAD) as app:
+ # -- State --
+ active_agent_name = gr.State(default_agent_name)
+ conv_list_state = gr.State([]) # list of {id, title, updated_at}
+ browser_state = gr.BrowserState(
+ {"agent_name": default_agent_name, "conv_id": None},
+ storage_key="cheddahbot_session",
+ )
+
+ # -- Sidebar --
+ with gr.Sidebar(label="CheddahBot", open=True, width=280):
+ gr.Markdown("### Agents")
+ agent_selector = gr.Radio(
+ choices=agent_choices,
+ value=default_agent_name,
+ label="Active Agent",
+ interactive=True,
+ elem_classes=["agent-radio"],
+ )
+
+ gr.Markdown("---")
+ new_chat_btn = gr.Button("+ New Chat", variant="secondary", size="sm")
+
+ gr.Markdown("### History")
+
+ @gr.render(inputs=[conv_list_state])
+ def render_conv_list(convs):
+ if not convs:
+ gr.Markdown("*No conversations yet*")
+ return
+ for conv in convs:
+ btn = gr.Button(
+ conv["title"] or "New Chat",
+ elem_classes=["conv-btn"],
+ variant="secondary",
+ size="sm",
+ key=f"conv-{conv['id']}",
+ )
+ btn.click(
+ on_load_conversation,
+ inputs=[gr.State(conv["id"]), active_agent_name],
+ outputs=[chatbot, conv_list_state, browser_state],
+ )
+
+ # -- Main area --
gr.Markdown("# CheddahBot", elem_classes=["contain"])
gr.Markdown(
f"*Chat Brain:* `{current_model}` | "
@@ -111,7 +230,6 @@ def create_ui(
scale=3,
)
refresh_btn = gr.Button("Refresh", scale=0, min_width=70)
- new_chat_btn = gr.Button("New Chat", scale=1, variant="secondary")
chatbot = gr.Chatbot(
label="Chat",
@@ -134,22 +252,61 @@ def create_ui(
sources=["upload", "microphone"],
)
+ # -- Helper functions --
+
+ def _get_agent(agent_name: str):
+ """Get agent by name, falling back to default."""
+ return registry.get(agent_name) or registry.default
+
+ def _refresh_conv_list(agent_name: str) -> list[dict]:
+ """Load conversation list for the given agent."""
+ agent = _get_agent(agent_name)
+ if not agent:
+ return []
+ return agent.db.list_conversations(limit=50, agent_name=agent_name)
+
# -- Event handlers --
def on_model_change(model_id):
llm.switch_model(model_id)
- return f"Switched to {model_id}"
def on_refresh_models():
models = llm.list_chat_models()
choices = [(m.name, m.id) for m in models]
return gr.update(choices=choices, value=llm.current_model)
- def on_new_chat():
- agent.new_conversation()
- return []
+ def on_agent_switch(agent_name, browser):
+ """Switch to a different agent: clear chat, refresh history."""
+ agent = _get_agent(agent_name)
+ if agent:
+ agent.new_conversation()
+ convs = _refresh_conv_list(agent_name)
+ browser = dict(browser) if browser else {}
+ browser["agent_name"] = agent_name
+ browser["conv_id"] = agent.conv_id if agent else None
+ return agent_name, [], convs, browser
- def on_user_message(message, chat_history):
+ def on_new_chat(agent_name, browser):
+ """Create a new conversation on the active agent."""
+ agent = _get_agent(agent_name)
+ if agent:
+ agent.new_conversation()
+ convs = _refresh_conv_list(agent_name)
+ browser = dict(browser) if browser else {}
+ browser["conv_id"] = agent.conv_id if agent else None
+ return [], convs, browser
+
+ def on_load_conversation(conv_id, agent_name):
+ """Load an existing conversation into the chatbot."""
+ agent = _get_agent(agent_name)
+ if not agent:
+ return [], [], {"agent_name": agent_name, "conv_id": None}
+ messages = agent.load_conversation(conv_id)
+ chatbot_msgs = _messages_to_chatbot(messages)
+ convs = _refresh_conv_list(agent_name)
+ return chatbot_msgs, convs, {"agent_name": agent_name, "conv_id": conv_id}
+
+ def on_user_message(message, chat_history, agent_name):
chat_history = chat_history or []
# Extract text and files from MultimodalTextbox
@@ -161,7 +318,7 @@ def create_ui(
files = []
if not text and not files:
- yield chat_history, gr.update(value=None)
+ yield chat_history, gr.update(value=None), gr.update()
return
# Handle audio files - transcribe them
@@ -197,9 +354,10 @@ def create_ui(
user_display += f"\n[Attached: {', '.join(file_names)}]"
chat_history = [*chat_history, {"role": "user", "content": user_display}]
- yield chat_history, gr.update(value=None)
+ yield chat_history, gr.update(value=None), gr.update()
# Stream assistant response
+ agent = _get_agent(agent_name)
try:
response_text = ""
chat_history = [*chat_history, {"role": "assistant", "content": ""}]
@@ -207,7 +365,7 @@ def create_ui(
for chunk in agent.respond(text, files=processed_files):
response_text += chunk
chat_history[-1] = {"role": "assistant", "content": response_text}
- yield chat_history, gr.update(value=None)
+ yield chat_history, gr.update(value=None), gr.update()
# If no response came through, show a fallback
if not response_text:
@@ -215,14 +373,48 @@ def create_ui(
"role": "assistant",
"content": "(No response received from model)",
}
- yield chat_history, gr.update(value=None)
+ yield chat_history, gr.update(value=None), gr.update()
except Exception as e:
log.error("Error in agent.respond: %s", e, exc_info=True)
- chat_history = [*chat_history, {"role": "assistant", "content": f"Error: {e}"}]
- yield chat_history, gr.update(value=None)
+ chat_history = [
+ *chat_history,
+ {"role": "assistant", "content": f"Error: {e}"},
+ ]
+ yield chat_history, gr.update(value=None), gr.update()
- def poll_pipeline_status():
+ # Refresh conversation list after message (title/updated_at may have changed)
+ convs = _refresh_conv_list(agent_name)
+ yield chat_history, gr.update(value=None), convs
+
+ def on_app_load(browser):
+ """Restore session from browser localStorage on page load."""
+ browser = browser or {}
+ agent_name = browser.get("agent_name", default_agent_name)
+ conv_id = browser.get("conv_id")
+
+ # Validate agent exists
+ agent = registry.get(agent_name)
+ if not agent:
+ agent_name = default_agent_name
+ agent = registry.default
+
+ chatbot_msgs = []
+ if conv_id and agent:
+ messages = agent.load_conversation(conv_id)
+ chatbot_msgs = _messages_to_chatbot(messages)
+ elif agent:
+ agent.ensure_conversation()
+ conv_id = agent.conv_id
+
+ convs = _refresh_conv_list(agent_name)
+ new_browser = {"agent_name": agent_name, "conv_id": conv_id}
+ return agent_name, agent_name, chatbot_msgs, convs, new_browser
+
+ def poll_pipeline_status(agent_name):
"""Poll the DB for pipeline progress updates."""
+ agent = _get_agent(agent_name)
+ if not agent:
+ return gr.update(value="", visible=False)
status = agent.db.kv_get("pipeline:status")
if status:
return gr.update(value=f"⏳ {status}", visible=True)
@@ -248,17 +440,35 @@ def create_ui(
model_dropdown.change(on_model_change, [model_dropdown], None)
refresh_btn.click(on_refresh_models, None, [model_dropdown])
- new_chat_btn.click(on_new_chat, None, [chatbot])
+
+ agent_selector.change(
+ on_agent_switch,
+ [agent_selector, browser_state],
+ [active_agent_name, chatbot, conv_list_state, browser_state],
+ )
+
+ new_chat_btn.click(
+ on_new_chat,
+ [active_agent_name, browser_state],
+ [chatbot, conv_list_state, browser_state],
+ )
msg_input.submit(
on_user_message,
- [msg_input, chatbot],
- [chatbot, msg_input],
+ [msg_input, chatbot, active_agent_name],
+ [chatbot, msg_input, conv_list_state],
+ )
+
+ # Restore session on page load
+ app.load(
+ on_app_load,
+ inputs=[browser_state],
+ outputs=[active_agent_name, agent_selector, chatbot, conv_list_state, browser_state],
)
# Pipeline status polling timer (every 3 seconds)
status_timer = gr.Timer(3)
- status_timer.tick(poll_pipeline_status, None, [pipeline_status])
+ status_timer.tick(poll_pipeline_status, [active_agent_name], [pipeline_status])
# Notification polling timer (every 10 seconds)
if notification_bus:
diff --git a/tests/test_db.py b/tests/test_db.py
index d70116a..8fab2d0 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -1,9 +1,63 @@
-"""Tests for the Database kv_scan and notifications methods."""
+"""Tests for the Database kv_scan, notifications, and conversations methods."""
from __future__ import annotations
import json
+from cheddahbot.db import Database
+
+
+class TestConversationsAgentName:
+ """Conversations are tagged by agent_name for per-agent history filtering."""
+
+ def test_create_with_default_agent_name(self, tmp_db):
+ tmp_db.create_conversation("conv1")
+ convs = tmp_db.list_conversations()
+ assert len(convs) == 1
+ assert convs[0]["agent_name"] == "default"
+
+ def test_create_with_custom_agent_name(self, tmp_db):
+ tmp_db.create_conversation("conv1", agent_name="writer")
+ convs = tmp_db.list_conversations()
+ assert convs[0]["agent_name"] == "writer"
+
+ def test_list_filters_by_agent_name(self, tmp_db):
+ tmp_db.create_conversation("c1", agent_name="default")
+ tmp_db.create_conversation("c2", agent_name="writer")
+ tmp_db.create_conversation("c3", agent_name="default")
+
+ default_convs = tmp_db.list_conversations(agent_name="default")
+ writer_convs = tmp_db.list_conversations(agent_name="writer")
+ all_convs = tmp_db.list_conversations()
+
+ assert len(default_convs) == 2
+ assert len(writer_convs) == 1
+ assert len(all_convs) == 3
+
+ def test_list_without_filter_returns_all(self, tmp_db):
+ tmp_db.create_conversation("c1", agent_name="a")
+ tmp_db.create_conversation("c2", agent_name="b")
+
+ convs = tmp_db.list_conversations()
+ assert len(convs) == 2
+
+ def test_list_returns_agent_name_in_results(self, tmp_db):
+ tmp_db.create_conversation("c1", agent_name="researcher")
+ convs = tmp_db.list_conversations()
+ assert "agent_name" in convs[0]
+ assert convs[0]["agent_name"] == "researcher"
+
+ def test_migration_idempotency(self, tmp_path):
+ """Running _init_schema twice doesn't error (ALTER TABLE is skipped)."""
+ db_path = tmp_path / "test_migrate.db"
+ db1 = Database(db_path)
+ db1.create_conversation("c1", agent_name="ops")
+ # Re-init on same DB file triggers migration again
+ db2 = Database(db_path)
+ convs = db2.list_conversations()
+ assert len(convs) == 1
+ assert convs[0]["agent_name"] == "ops"
+
class TestKvScan:
"""kv_scan is used to find all tracked ClickUp task states efficiently.
diff --git a/tests/test_ui_helpers.py b/tests/test_ui_helpers.py
new file mode 100644
index 0000000..26d32d0
--- /dev/null
+++ b/tests/test_ui_helpers.py
@@ -0,0 +1,53 @@
+"""Tests for UI helper functions."""
+
+from __future__ import annotations
+
+from cheddahbot.ui import _messages_to_chatbot
+
+
+class TestMessagesToChatbot:
+ """_messages_to_chatbot converts DB messages to Gradio chatbot format."""
+
+ def test_converts_user_and_assistant(self):
+ messages = [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there"},
+ ]
+ result = _messages_to_chatbot(messages)
+ assert result == [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there"},
+ ]
+
+ def test_skips_tool_messages(self):
+ messages = [
+ {"role": "user", "content": "Search for X"},
+ {"role": "assistant", "content": "Let me search..."},
+ {"role": "tool", "content": '{"results": []}'},
+ {"role": "assistant", "content": "Here are the results."},
+ ]
+ result = _messages_to_chatbot(messages)
+ assert len(result) == 3
+ assert all(m["role"] in ("user", "assistant") for m in result)
+
+ def test_skips_empty_content(self):
+ messages = [
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": ""},
+ {"role": "assistant", "content": "Real response"},
+ ]
+ result = _messages_to_chatbot(messages)
+ assert len(result) == 2
+ assert result[1]["content"] == "Real response"
+
+ def test_empty_input(self):
+ assert _messages_to_chatbot([]) == []
+
+ def test_skips_system_messages(self):
+ messages = [
+ {"role": "system", "content": "You are a bot"},
+ {"role": "user", "content": "Hi"},
+ ]
+ result = _messages_to_chatbot(messages)
+ assert len(result) == 1
+ assert result[0]["role"] == "user"