From 89cfe1be0db75d9e8e1267c1ca6ffa5e7a7ea3e4 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone <8633216+AlexanderWhitestone@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:14:37 -0500 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.6 --- docker-compose.test.yml | 10 ++ docker/Dockerfile.test | 11 ++- src/dashboard/app.py | 24 +++-- src/dashboard/routes/voice.py | 36 ++++++- src/dashboard/templates/base.html | 96 ++++++++++++------- src/dashboard/templates/index.html | 4 +- src/dashboard/templates/partials/history.html | 1 + src/self_coding/self_modify/loop.py | 4 +- src/timmy/backends.py | 2 +- static/style.css | 55 +++++++++++ tests/timmy/test_introspection.py | 3 +- tests/tools/test_path_resolution.py | 3 +- 12 files changed, 194 insertions(+), 55 deletions(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 39bd62d..6889dbb 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -39,6 +39,14 @@ services: - ./src:/app/src:ro - ./tests:/app/tests:ro - ./static:/app/static:ro + - ./hands:/app/hands:ro + - ./docker:/app/docker:ro + - ./Dockerfile:/app/Dockerfile:ro + - ./docker-compose.yml:/app/docker-compose.yml:ro + - ./docker-compose.dev.yml:/app/docker-compose.dev.yml:ro + - ./docker-compose.prod.yml:/app/docker-compose.prod.yml:ro + - ./docker-compose.test.yml:/app/docker-compose.test.yml:ro + - ./docker-compose.microservices.yml:/app/docker-compose.microservices.yml:ro - ./pyproject.toml:/app/pyproject.toml:ro - test-data:/app/data environment: @@ -67,6 +75,7 @@ services: volumes: - ./src:/app/src:ro - ./static:/app/static:ro + - ./hands:/app/hands:ro - test-data:/app/data environment: DEBUG: "true" @@ -117,6 +126,7 @@ services: - agents volumes: - ./src:/app/src:ro + - ./hands:/app/hands:ro - test-data:/app/data environment: COORDINATOR_URL: "http://dashboard:8000" diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index 5db3078..602ac2d 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -39,7 +39,7 @@ FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - curl git \ + curl git fontconfig fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* # Copy installed packages from builder @@ -48,7 +48,14 @@ COPY --from=builder /usr/local/lib/python3.12/site-packages \ COPY --from=builder /usr/local/bin /usr/local/bin # Create directories for bind mounts -RUN mkdir -p /app/src /app/tests /app/static /app/data +RUN mkdir -p /app/src /app/tests /app/static /app/hands /app/data /app/docker + +# Initialize a minimal git repo so git-dependent code (GitSafety, repo_root +# detection) works correctly inside the container. +RUN git config --global user.email "timmy@test" \ + && git config --global user.name "Timmy Test" \ + && git init /app \ + && git -C /app commit --allow-empty -m "init" ENV PYTHONPATH=/app/src:/app/tests ENV PYTHONUNBUFFERED=1 diff --git a/src/dashboard/app.py b/src/dashboard/app.py index f0d3eb1..d63c1da 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -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()) diff --git a/src/dashboard/routes/voice.py b/src/dashboard/routes/voice.py index d7ee407..7a67f08 100644 --- a/src/dashboard/routes/voice.py +++ b/src/dashboard/routes/voice.py @@ -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") diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index d2f27fd..3522508 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -9,13 +9,14 @@ {% block title %}Timmy Time — Mission Control{% endblock %} - + + - + {% block extra_styles %}{% endblock %} - - - + + +
@@ -28,26 +29,31 @@ @@ -66,24 +72,29 @@ HOME + TASKS BRIEFING THINKING MISSION CONTROL SWARM - SPARK - MARKET - TOOLS - EVENTS BUGS - LEDGER + + SPARK MEMORY - ROUTER - GROK - UPGRADES - SELF-CODING + MARKET + HANDS WORK ORDERS + UPGRADES + SELF-CODING + + TOOLS + EVENTS + ROUTER + GROK + + LEDGER CREATIVE VOICE MOBILE @@ -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'); + } + }); + } diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html index 218dde8..465bfab 100644 --- a/src/dashboard/templates/index.html +++ b/src/dashboard/templates/index.html @@ -11,7 +11,7 @@
// AGENTS
@@ -23,7 +23,7 @@
// SYSTEM HEALTH
diff --git a/src/dashboard/templates/partials/history.html b/src/dashboard/templates/partials/history.html index 26165d5..5698a78 100644 --- a/src/dashboard/templates/partials/history.html +++ b/src/dashboard/templates/partials/history.html @@ -23,3 +23,4 @@
Mission Control initialized. Timmy ready — awaiting input.
{% endif %} + diff --git a/src/self_coding/self_modify/loop.py b/src/self_coding/self_modify/loop.py index afb2dbf..95ae6e5 100644 --- a/src/self_coding/self_modify/loop.py +++ b/src/self_coding/self_modify/loop.py @@ -36,8 +36,8 @@ from config import settings logger = logging.getLogger(__name__) -# Project root — two levels up from src/self_modify/ -PROJECT_ROOT = Path(__file__).parent.parent.parent +# Project root — use settings.repo_root (works in Docker and local dev) +PROJECT_ROOT = Path(settings.repo_root) # Reports directory REPORTS_DIR = PROJECT_ROOT / "data" / "self_modify_reports" diff --git a/src/timmy/backends.py b/src/timmy/backends.py index 0e1b7e1..e69e7b7 100644 --- a/src/timmy/backends.py +++ b/src/timmy/backends.py @@ -194,7 +194,7 @@ class GrokBackend: ) -> None: from config import settings - self._api_key = api_key or settings.xai_api_key + self._api_key = api_key if api_key is not None else settings.xai_api_key self._model = model or settings.grok_default_model self._history: list[dict[str, str]] = [] self.stats = GrokUsageStats() diff --git a/static/style.css b/static/style.css index c6fca99..75940c4 100644 --- a/static/style.css +++ b/static/style.css @@ -145,6 +145,61 @@ a:hover { color: var(--orange); } } .mc-test-link:hover { border-color: var(--purple); color: var(--purple); } +/* ── Named link colors ───────────────────────────── */ +.mc-link-thinking { color: #c084fc; } +.mc-link-bugs { color: #ff6b6b; } +.mc-link-grok { color: #00ff88; } + +/* ── Desktop "More" dropdown ─────────────────────── */ +.mc-nav-dropdown { position: relative; } +.mc-dropdown-toggle { + background: none; + cursor: pointer; +} +.mc-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 6px); + right: 0; + background: rgba(17, 8, 32, 0.96); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 0; + min-width: 160px; + z-index: 150; + max-height: 70vh; + overflow-y: auto; + flex-direction: column; +} +.mc-nav-dropdown:hover .mc-dropdown-menu, +.mc-nav-dropdown.open .mc-dropdown-menu { + display: flex; +} +.mc-dropdown-menu .mc-test-link { + display: block; + padding: 6px 14px; + border: none; + border-radius: 0; + white-space: nowrap; +} +.mc-dropdown-menu .mc-test-link:hover { + background: rgba(124, 58, 237, 0.12); +} + +/* ── Mobile section labels ───────────────────────── */ +.mc-mobile-section-label { + font-size: 9px; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.2em; + padding: 12px 20px 4px; + text-transform: uppercase; + border-top: 1px solid var(--border); + margin-top: 4px; +} + /* ── Hamburger (mobile only) ───────────────────── */ .mc-hamburger { display: none; diff --git a/tests/timmy/test_introspection.py b/tests/timmy/test_introspection.py index ab552aa..f0f2351 100644 --- a/tests/timmy/test_introspection.py +++ b/tests/timmy/test_introspection.py @@ -37,7 +37,8 @@ def test_get_system_info_contains_repo_root(): assert "repo_root" in info assert info["repo_root"] == settings.repo_root - assert "Timmy-time-dashboard" in info["repo_root"] + # In Docker the CWD is /app, so just verify it's a non-empty path + assert len(info["repo_root"]) > 0 def test_check_ollama_health_returns_dict(): diff --git a/tests/tools/test_path_resolution.py b/tests/tools/test_path_resolution.py index d0ee1e1..789f23d 100644 --- a/tests/tools/test_path_resolution.py +++ b/tests/tools/test_path_resolution.py @@ -20,8 +20,9 @@ def test_resolve_path_relative_to_repo(): result = _resolve_path("src/config.py") - assert "Timmy-time-dashboard" in str(result) + # In Docker the repo root is /app; locally it contains Timmy-time-dashboard assert result.name == "config.py" + assert "src" in str(result) def test_resolve_path_absolute():