fix: Docker-first test suite, UX improvements, and bug fixes (#100)

Dashboard UX:
- Restructure nav from 22 flat links to 6 core + MORE dropdown
- Add mobile nav section labels (Core, Intelligence, Agents, System, Commerce)
- Defer marked.js and dompurify.js loading, consolidate CDN to jsdelivr
- Optimize font weights (drop unused 300/500), bump style.css cache buster
- Remove duplicate HTMX load triggers from sidebar and health panels

Bug fixes:
- Fix Timmy showing OFFLINE by registering after swarm recovery sweep
- Fix ThinkingEngine await bug with asyncio.run_coroutine_threadsafe
- Fix chat auto-scroll by calling scrollChat() after history partial loads
- Add missing /voice/button page and /voice/command endpoint
- Fix Grok api_key="" treated as falsy falling through to env key
- Fix self_modify PROJECT_ROOT using settings.repo_root instead of __file__

Docker test infrastructure:
- Bind-mount hands/, docker/, Dockerfiles, and compose files into test container
- Add fontconfig + fonts-dejavu-core for creative/assembler TextClip tests
- Initialize minimal git repo in Dockerfile.test for GitSafety compatibility
- Fix introspection and path resolution tests for Docker /app context

All 1863 tests pass in Docker (0 failures, 77 skipped).

Co-authored-by: Alexander Payne <apayne@MM.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-02-28 22:14:37 -05:00
committed by GitHub
parent 6e67c3b421
commit 89cfe1be0d
12 changed files with 194 additions and 55 deletions

View File

@@ -275,7 +275,11 @@ async def _task_processor_loop() -> None:
def handle_thought(task):
from timmy.thinking import thinking_engine
try:
result = thinking_engine.think_once()
loop = asyncio.get_event_loop()
future = asyncio.run_coroutine_threadsafe(
thinking_engine.think_once(), loop
)
result = future.result(timeout=120)
return str(result) if result else "Thought completed"
except Exception as e:
logger.error("Thought processing failed: %s", e)
@@ -457,15 +461,7 @@ async def lifespan(app: FastAPI):
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
# Register Timmy in swarm registry
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
capabilities="chat,reasoning,research,planning",
agent_id="timmy",
)
# Run swarm recovery and log summary
# Run swarm recovery first (offlines all stale agents)
from swarm.coordinator import coordinator as swarm_coordinator
swarm_coordinator.initialize()
rec = swarm_coordinator._recovery_summary
@@ -476,6 +472,14 @@ async def lifespan(app: FastAPI):
rec["agents_offlined"],
)
# Register Timmy AFTER recovery sweep so status sticks as "idle"
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
capabilities="chat,reasoning,research,planning",
agent_id="timmy",
)
# Spawn persona agents in background
persona_task = asyncio.create_task(_spawn_persona_agents_background())

View File

@@ -1,12 +1,16 @@
"""Voice routes — /voice/* and /voice/enhanced/* endpoints.
Provides NLU intent detection, TTS control, and the full voice-to-action
pipeline (detect intent → execute → optionally speak).
Provides NLU intent detection, TTS control, the full voice-to-action
pipeline (detect intent → execute → optionally speak), and the voice
button UI page.
"""
import logging
from fastapi import APIRouter, Form
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from integrations.voice.nlu import detect_intent, extract_command
from timmy.agent import create_timmy
@@ -14,6 +18,7 @@ from timmy.agent import create_timmy
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice", tags=["voice"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.post("/nlu")
@@ -56,6 +61,31 @@ async def tts_speak(text: str = Form(...)):
return {"spoken": False, "reason": str(exc)}
# ── Voice button page ────────────────────────────────────────────────────
@router.get("/button", response_class=HTMLResponse)
async def voice_button_page(request: Request):
"""Render the voice button UI."""
return templates.TemplateResponse(request, "voice_button.html")
@router.post("/command")
async def voice_command(text: str = Form(...)):
"""Process a voice command (used by voice_button.html).
Wraps the enhanced pipeline and returns the result in the format
the voice button template expects.
"""
result = await process_voice_input(text=text, speak_response=False)
return {
"command": {
"intent": result["intent"],
"response": result["response"] or result.get("error", "No response"),
}
}
# ── Enhanced voice pipeline ──────────────────────────────────────────────
@router.post("/enhanced/process")

View File

@@ -9,13 +9,14 @@
<title>{% block title %}Timmy Time — Mission Control{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" />
<link rel="stylesheet" href="/static/style.css?v=4" />
<link rel="stylesheet" href="/static/style.css?v=5" />
{% block extra_styles %}{% endblock %}
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.3/dist/htmx.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
</head>
<body>
<header class="mc-header">
@@ -28,26 +29,31 @@
<div class="mc-header-right mc-desktop-nav">
<a href="/tasks" class="mc-test-link">TASKS</a>
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/thinking" class="mc-test-link" style="color:#c084fc;">THINKING</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CONTROL</a>
<a href="/thinking" class="mc-test-link mc-link-thinking">THINKING</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CTRL</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
<a href="/spark/ui" class="mc-test-link">SPARK</a>
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
<a href="/tools" class="mc-test-link">TOOLS</a>
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
<a href="/bugs" class="mc-test-link" style="color:#ff6b6b;">BUGS</a>
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
<a href="/memory" class="mc-test-link">MEMORY</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/grok/status" class="mc-test-link" style="color:#00ff88;">GROK</a>
<a href="/self-modify/queue" class="mc-test-link">UPGRADES</a>
<a href="/self-coding" class="mc-test-link">SELF-CODING</a>
<a href="/hands" class="mc-test-link">HANDS</a>
<a href="/work-orders/queue" class="mc-test-link">WORK ORDERS</a>
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
<a href="/voice/button" class="mc-test-link">VOICE</a>
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
<a href="/mobile/local" class="mc-test-link" title="Local AI on iPhone">LOCAL AI</a>
<a href="/bugs" class="mc-test-link mc-link-bugs">BUGS</a>
<div class="mc-nav-dropdown">
<button class="mc-test-link mc-dropdown-toggle" aria-expanded="false">MORE &#x25BE;</button>
<div class="mc-dropdown-menu">
<a href="/spark/ui" class="mc-test-link">SPARK</a>
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
<a href="/tools" class="mc-test-link">TOOLS</a>
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
<a href="/memory" class="mc-test-link">MEMORY</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/grok/status" class="mc-test-link mc-link-grok">GROK</a>
<a href="/self-modify/queue" class="mc-test-link">UPGRADES</a>
<a href="/self-coding" class="mc-test-link">SELF-CODING</a>
<a href="/hands" class="mc-test-link">HANDS</a>
<a href="/work-orders/queue" class="mc-test-link">WORK ORDERS</a>
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
<a href="/voice/button" class="mc-test-link">VOICE</a>
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
<a href="/mobile/local" class="mc-test-link" title="Local AI on iPhone">LOCAL AI</a>
</div>
</div>
<button id="enable-notifications" class="mc-test-link" style="background:none;cursor:pointer;" title="Enable notifications">&#x1F514;</button>
<span class="mc-time" id="clock"></span>
</div>
@@ -66,24 +72,29 @@
<span class="mc-time" id="clock-mobile"></span>
</div>
<a href="/" class="mc-mobile-link">HOME</a>
<div class="mc-mobile-section-label">CORE</div>
<a href="/tasks" class="mc-mobile-link">TASKS</a>
<a href="/briefing" class="mc-mobile-link">BRIEFING</a>
<a href="/thinking" class="mc-mobile-link">THINKING</a>
<a href="/swarm/mission-control" class="mc-mobile-link">MISSION CONTROL</a>
<a href="/swarm/live" class="mc-mobile-link">SWARM</a>
<a href="/spark/ui" class="mc-mobile-link">SPARK</a>
<a href="/marketplace/ui" class="mc-mobile-link">MARKET</a>
<a href="/tools" class="mc-mobile-link">TOOLS</a>
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/bugs" class="mc-mobile-link">BUGS</a>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<div class="mc-mobile-section-label">INTELLIGENCE</div>
<a href="/spark/ui" class="mc-mobile-link">SPARK</a>
<a href="/memory" class="mc-mobile-link">MEMORY</a>
<a href="/router/status" class="mc-mobile-link">ROUTER</a>
<a href="/grok/status" class="mc-mobile-link">GROK</a>
<a href="/self-modify/queue" class="mc-mobile-link">UPGRADES</a>
<a href="/self-coding" class="mc-mobile-link">SELF-CODING</a>
<a href="/marketplace/ui" class="mc-mobile-link">MARKET</a>
<div class="mc-mobile-section-label">AGENTS</div>
<a href="/hands" class="mc-mobile-link">HANDS</a>
<a href="/work-orders/queue" class="mc-mobile-link">WORK ORDERS</a>
<a href="/self-modify/queue" class="mc-mobile-link">UPGRADES</a>
<a href="/self-coding" class="mc-mobile-link">SELF-CODING</a>
<div class="mc-mobile-section-label">SYSTEM</div>
<a href="/tools" class="mc-mobile-link">TOOLS</a>
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/router/status" class="mc-mobile-link">ROUTER</a>
<a href="/grok/status" class="mc-mobile-link">GROK</a>
<div class="mc-mobile-section-label">COMMERCE</div>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>
<a href="/voice/button" class="mc-mobile-link">VOICE</a>
<a href="/mobile" class="mc-mobile-link">MOBILE</a>
@@ -128,6 +139,25 @@
a.classList.add('active');
}
});
// Desktop "More" dropdown toggle
var dropdownToggle = document.querySelector('.mc-dropdown-toggle');
if (dropdownToggle) {
dropdownToggle.addEventListener('click', function(e) {
e.stopPropagation();
var dd = this.closest('.mc-nav-dropdown');
var isOpen = dd.classList.toggle('open');
this.setAttribute('aria-expanded', isOpen);
});
document.addEventListener('click', function() {
var dd = document.querySelector('.mc-nav-dropdown');
if (dd) {
dd.classList.remove('open');
var btn = dd.querySelector('.mc-dropdown-toggle');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
});
}
</script>
<script src="/static/notifications.js"></script>
</body>

View File

@@ -11,7 +11,7 @@
<!-- Agents (HTMX-polled from registry) -->
<div class="card mc-panel"
hx-get="/swarm/agents/sidebar"
hx-trigger="load, every 10s"
hx-trigger="every 10s"
hx-target="this"
hx-swap="innerHTML">
<div class="card-header mc-panel-header">// AGENTS</div>
@@ -23,7 +23,7 @@
<!-- System Health (HTMX polled) -->
<div class="card mc-panel"
hx-get="/health/status"
hx-trigger="load, every 30s"
hx-trigger="every 30s"
hx-target="this"
hx-swap="innerHTML">
<div class="card-header mc-panel-header">// SYSTEM HEALTH</div>

View File

@@ -23,3 +23,4 @@
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
</div>
{% endif %}
<script>if(typeof scrollChat==='function'){setTimeout(scrollChat,50);}</script>