From 32b9c378e7ef13ff95805482ff139ad7b0238537 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 12:32:34 +0800 Subject: [PATCH 1/7] feat: add RLCR visualization dashboard (/humanize:viz) Add a local web dashboard for real-time monitoring and historical analysis of RLCR loop sessions. Backend (Python/Flask): - parser.py: RLCR session data parser (YAML/Markdown, bilingual) - app.py: Flask server with REST API, WebSocket, sanitized issue gen - watcher.py: watchdog file observer with debounce - analyzer.py: cross-session statistics - exporter.py: Markdown report generation Frontend (vanilla HTML/CSS/JS): - Snake-path node layout with zoom/pan canvas - Click-to-expand flyout detail panel (animates from node position) - Session overview sidebar (AC checklist, verdict distribution) - Chart.js analytics with empty-state handling - Round verdict timeline visualization - Mission Control aesthetic (Archivo + DM Sans + JetBrains Mono) Integration: - setup-rlcr-loop.sh: prompt to launch viz on loop start - cancel-rlcr-loop.sh: auto-stop viz on loop cancel - commands/viz.md: /humanize:viz start|stop|status - skills/humanize-viz: skill definition Shell scripts: - viz-start.sh: venv auto-creation, port scan, tmux launch - viz-stop.sh: tmux kill + cleanup - viz-status.sh: health check + stale detection Signed-off-by: Chao Liu --- commands/viz.md | 43 ++ scripts/cancel-rlcr-loop.sh | 6 + scripts/setup-rlcr-loop.sh | 17 + skills/humanize-viz/SKILL.md | 36 ++ viz/scripts/viz-start.sh | 111 +++++ viz/scripts/viz-status.sh | 33 ++ viz/scripts/viz-stop.sh | 21 + viz/server/analyzer.py | 122 +++++ viz/server/app.py | 604 ++++++++++++++++++++++++ viz/server/exporter.py | 85 ++++ viz/server/parser.py | 434 ++++++++++++++++++ viz/server/requirements.txt | 5 + viz/server/watcher.py | 112 +++++ viz/static/css/layout.css | 860 +++++++++++++++++++++++++++++++++++ viz/static/css/theme.css | 420 +++++++++++++++++ viz/static/index.html | 67 +++ viz/static/js/actions.js | 124 +++++ viz/static/js/app.js | 489 ++++++++++++++++++++ viz/static/js/charts.js | 155 +++++++ viz/static/js/i18n.js | 93 ++++ viz/static/js/pipeline.js | 302 ++++++++++++ 21 files changed, 4139 insertions(+) create mode 100644 commands/viz.md create mode 100644 skills/humanize-viz/SKILL.md create mode 100755 viz/scripts/viz-start.sh create mode 100755 viz/scripts/viz-status.sh create mode 100755 viz/scripts/viz-stop.sh create mode 100644 viz/server/analyzer.py create mode 100644 viz/server/app.py create mode 100644 viz/server/exporter.py create mode 100644 viz/server/parser.py create mode 100644 viz/server/requirements.txt create mode 100644 viz/server/watcher.py create mode 100644 viz/static/css/layout.css create mode 100644 viz/static/css/theme.css create mode 100644 viz/static/index.html create mode 100644 viz/static/js/actions.js create mode 100644 viz/static/js/app.js create mode 100644 viz/static/js/charts.js create mode 100644 viz/static/js/i18n.js create mode 100644 viz/static/js/pipeline.js diff --git a/commands/viz.md b/commands/viz.md new file mode 100644 index 00000000..1e265ca4 --- /dev/null +++ b/commands/viz.md @@ -0,0 +1,43 @@ +# Humanize Viz Dashboard + +Manage the local web visualization dashboard for RLCR loop sessions. + +## Usage + +``` +/humanize:viz +``` + +### Subcommands + +- `start` — Launch the dashboard server (creates venv on first run, opens browser) +- `stop` — Stop the dashboard server +- `status` — Check if the dashboard server is running + +## Implementation + +Run the appropriate shell script based on the subcommand: + +```bash +# Determine paths +VIZ_DIR="${CLAUDE_PLUGIN_ROOT}/viz/scripts" +PROJECT_DIR="$(pwd)" + +# Route subcommand +case "$1" in + start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;; + stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;; + status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; + *) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; +esac +``` + +If no subcommand is provided, default to `status`. + +## Requirements + +- Python 3 (for Flask server) +- tmux (for background process management) +- A `.humanize/` directory in the project (created by RLCR loop) + +The first `start` will automatically create a Python venv in `.humanize/viz-venv/` and install dependencies. diff --git a/scripts/cancel-rlcr-loop.sh b/scripts/cancel-rlcr-loop.sh index 8ec303f0..a0c6e0a9 100755 --- a/scripts/cancel-rlcr-loop.sh +++ b/scripts/cancel-rlcr-loop.sh @@ -165,6 +165,12 @@ mv "$ACTIVE_STATE_FILE" "$LOOP_DIR/cancel-state.md" # Output Result # ======================================== +# Stop viz dashboard if running +VIZ_STOP="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/../viz/scripts/viz-stop.sh" +if [[ -f "$VIZ_STOP" ]]; then + bash "$VIZ_STOP" "$PROJECT_ROOT" 2>/dev/null || true +fi + if [[ "$LOOP_STATE" == "NORMAL_LOOP" ]]; then echo "CANCELLED" echo "Cancelled RLCR loop (was at round $CURRENT_ROUND of $MAX_ITERATIONS)." diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 9d45363c..2904a060 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -1500,6 +1500,23 @@ To cancel: /humanize:cancel-rlcr-loop EOF fi +# ─── Viz Dashboard Prompt ─── +# Offer to launch the web visualization dashboard +VIZ_SCRIPT="$SCRIPT_DIR/../viz/scripts/viz-start.sh" +if [[ -f "$VIZ_SCRIPT" ]] && command -v tmux &>/dev/null && command -v python3 &>/dev/null; then + echo "" + echo "[Viz] Web dashboard is available for this RLCR session." + echo "[Viz] Launch with: bash \"$VIZ_SCRIPT\" \"$PROJECT_ROOT\"" + echo "[Viz] Or run: /humanize:viz start" + echo "" + + # Auto-start if viz_auto_start is set in config + if [[ "${VIZ_AUTO_START:-false}" == "true" ]]; then + echo "[Viz] Auto-starting dashboard..." + bash "$VIZ_SCRIPT" "$PROJECT_ROOT" 2>/dev/null & + fi +fi + # Output the initial prompt cat "$LOOP_DIR/round-0-prompt.md" diff --git a/skills/humanize-viz/SKILL.md b/skills/humanize-viz/SKILL.md new file mode 100644 index 00000000..74860bab --- /dev/null +++ b/skills/humanize-viz/SKILL.md @@ -0,0 +1,36 @@ +--- +name: humanize-viz +description: Launch and manage the RLCR loop visualization dashboard — a local web UI showing round nodes, session analytics, and methodology reports +triggers: + - viz + - dashboard + - visualization +--- + +# Humanize Viz + +Manage the RLCR loop visualization dashboard. + +## How to Use + +Route the subcommand to the appropriate viz script: + +```bash +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-start.sh" "" # start +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-stop.sh" "" # stop +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-status.sh" "" # status +``` + +Where `` is the current working directory. + +### Subcommands + +- **start**: Creates a Python venv (first run), installs Flask dependencies, finds an available port (18000-18099), launches the server in a tmux session, and opens the browser. +- **stop**: Kills the tmux session and cleans up the port file. +- **status**: Reports whether the server is running and its URL. + +### Prerequisites + +- Python 3 with venv support +- tmux +- A `.humanize/` directory in the project diff --git a/viz/scripts/viz-start.sh b/viz/scripts/viz-start.sh new file mode 100755 index 00000000..1b1d06b1 --- /dev/null +++ b/viz/scripts/viz-start.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Launch the Humanize Viz dashboard server in a tmux session. +# Usage: viz-start.sh [--project ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VIZ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +VENV_DIR="$HUMANIZE_DIR/viz-venv" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" +REQUIREMENTS="$VIZ_ROOT/server/requirements.txt" +APP_ENTRY="$VIZ_ROOT/server/app.py" +STATIC_DIR="$VIZ_ROOT/static" + +# Check .humanize/ directory exists +if [[ ! -d "$HUMANIZE_DIR" ]]; then + echo "Error: No .humanize/ directory found in $PROJECT_DIR" >&2 + echo "This command must be run in a project with humanize initialized." >&2 + exit 1 +fi + +# Check if already running +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + # Verify port is still alive + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server already running at http://localhost:$port" + exit 0 + fi + # Stale session — clean up + echo "Cleaning up stale session..." + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + rm -f "$PORT_FILE" + else + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + fi +fi + +# Clean up stale port file if tmux session is gone +if [[ -f "$PORT_FILE" ]] && ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + rm -f "$PORT_FILE" +fi + +# Create venv if it doesn't exist +if [[ ! -d "$VENV_DIR" ]]; then + echo "Creating Python virtual environment..." + python3 -m venv "$VENV_DIR" + echo "Installing dependencies..." + "$VENV_DIR/bin/pip" install --quiet -r "$REQUIREMENTS" + echo "Dependencies installed." +else + # Quick check: reinstall if requirements changed + if [[ "$REQUIREMENTS" -nt "$VENV_DIR/.requirements_installed" ]]; then + echo "Updating dependencies..." + "$VENV_DIR/bin/pip" install --quiet -r "$REQUIREMENTS" + touch "$VENV_DIR/.requirements_installed" + fi +fi +touch "$VENV_DIR/.requirements_installed" + +# Find available port (scan 18000-18099) +find_port() { + for port in $(seq 18000 18099); do + if ! (echo >/dev/tcp/localhost/$port) 2>/dev/null; then + echo "$port" + return 0 + fi + done + echo "Error: No available port in range 18000-18099" >&2 + return 1 +} + +PORT=$(find_port) +echo "$PORT" > "$PORT_FILE" + +# Launch Flask in tmux +tmux new-session -d -s "$TMUX_SESSION" \ + "$VENV_DIR/bin/python" "$APP_ENTRY" \ + --port "$PORT" \ + --project "$PROJECT_DIR" \ + --static "$STATIC_DIR" + +echo "Viz server starting on http://localhost:$PORT" + +# Wait briefly for server to be ready +for i in $(seq 1 10); do + if curl -s --max-time 1 "http://localhost:$PORT/api/health" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +# Open browser +if command -v xdg-open &>/dev/null; then + xdg-open "http://localhost:$PORT" 2>/dev/null & +elif command -v open &>/dev/null; then + open "http://localhost:$PORT" 2>/dev/null & +elif command -v wslview &>/dev/null; then + wslview "http://localhost:$PORT" 2>/dev/null & +else + echo "Open http://localhost:$PORT in your browser." +fi + +echo "Viz dashboard is ready at http://localhost:$PORT" +echo "Run 'viz-stop.sh' or '/humanize:viz stop' to stop the server." diff --git a/viz/scripts/viz-status.sh b/viz/scripts/viz-status.sh new file mode 100755 index 00000000..de8db163 --- /dev/null +++ b/viz/scripts/viz-status.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Check the status of the Humanize Viz dashboard server. +# Usage: viz-status.sh [--project ] + +set -euo pipefail + +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" + +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server running at http://localhost:$port" + exit 0 + fi + fi + # tmux session exists but server not responding — stale + echo "Viz server is not running (stale session detected, cleaning up)." + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + rm -f "$PORT_FILE" + exit 1 +else + if [[ -f "$PORT_FILE" ]]; then + rm -f "$PORT_FILE" + fi + echo "Viz server is not running." + exit 1 +fi diff --git a/viz/scripts/viz-stop.sh b/viz/scripts/viz-stop.sh new file mode 100755 index 00000000..0c622aaa --- /dev/null +++ b/viz/scripts/viz-stop.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Stop the Humanize Viz dashboard server. +# Usage: viz-stop.sh [--project ] + +set -euo pipefail + +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" + +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + tmux kill-session -t "$TMUX_SESSION" + rm -f "$PORT_FILE" + echo "Viz server stopped." +else + rm -f "$PORT_FILE" + echo "Viz server is not running." +fi diff --git a/viz/server/analyzer.py b/viz/server/analyzer.py new file mode 100644 index 00000000..952b1727 --- /dev/null +++ b/viz/server/analyzer.py @@ -0,0 +1,122 @@ +"""Cross-session analytics for RLCR loop data. + +Computes statistics across multiple sessions: efficiency metrics, +quality indicators, verdict distributions, and BitLesson growth. +""" + +def compute_analytics(sessions): + """Compute cross-session statistics from a list of parsed sessions.""" + if not sessions: + return _empty_analytics() + + total = len(sessions) + completed = sum(1 for s in sessions if s['status'] == 'complete') + total_rounds = [s['current_round'] for s in sessions if s['current_round'] > 0] + avg_rounds = round(sum(total_rounds) / len(total_rounds), 1) if total_rounds else 0 + + # Verdict distribution — only count rounds that have an actual review result + verdict_counts = {'advanced': 0, 'stalled': 0, 'regressed': 0, 'complete': 0} + for s in sessions: + for r in s['rounds']: + if r.get('review_result') is None: + continue + v = r.get('verdict', 'unknown') + if v != 'unknown': + verdict_counts[v] = verdict_counts.get(v, 0) + 1 + + # P0-P9 distribution + p_distribution = {} + for s in sessions: + for r in s['rounds']: + for level, count in r.get('p_issues', {}).items(): + p_distribution[level] = p_distribution.get(level, 0) + count + + # Per-session stats for charts + session_stats = [] + cumulative_bitlesson = 0 + bitlesson_growth = [] + + for s in sessions: + rounds_count = s['current_round'] + + # Average round duration + durations = [r['duration_minutes'] for r in s['rounds'] if r.get('duration_minutes')] + avg_duration = round(sum(durations) / len(durations), 1) if durations else None + + # First COMPLETE round + first_complete = None + for r in s['rounds']: + if r.get('verdict') == 'complete': + first_complete = r['number'] + break + + # Rework count (rounds after review phase started) + rework = 0 + in_review = False + for r in s['rounds']: + if r.get('phase') == 'code_review': + in_review = True + if in_review: + rework += 1 + + # Verdict breakdown for this session + sv = {'advanced': 0, 'stalled': 0, 'regressed': 0} + for r in s['rounds']: + v = r.get('verdict', '') + if v in sv: + sv[v] += 1 + + # BitLesson count + bl_count = sum(1 for r in s['rounds'] if r.get('bitlesson_delta') in ('add', 'update')) + cumulative_bitlesson += bl_count + + bitlesson_growth.append({ + 'session_id': s['id'], + 'cumulative': cumulative_bitlesson, + 'delta': bl_count, + }) + + session_stats.append({ + 'session_id': s['id'], + 'status': s['status'], + 'rounds': rounds_count, + 'avg_duration_minutes': avg_duration, + 'first_complete_round': first_complete, + 'rework_count': rework, + 'ac_completion_rate': round(s['ac_done'] / s['ac_total'] * 100, 1) if s['ac_total'] > 0 else 0, + 'verdict_breakdown': sv, + }) + + # Total BitLessons (count from bitlesson.md if available, else estimate) + total_bitlessons = cumulative_bitlesson + + return { + 'overview': { + 'total_sessions': total, + 'completed_sessions': completed, + 'completion_rate': round(completed / total * 100, 1) if total > 0 else 0, + 'average_rounds': avg_rounds, + 'total_bitlessons': total_bitlessons, + }, + 'verdict_distribution': verdict_counts, + 'p_distribution': p_distribution, + 'session_stats': session_stats, + 'bitlesson_growth': bitlesson_growth, + } + + +def _empty_analytics(): + """Return empty analytics structure.""" + return { + 'overview': { + 'total_sessions': 0, + 'completed_sessions': 0, + 'completion_rate': 0, + 'average_rounds': 0, + 'total_bitlessons': 0, + }, + 'verdict_distribution': {}, + 'p_distribution': {}, + 'session_stats': [], + 'bitlesson_growth': [], + } diff --git a/viz/server/app.py b/viz/server/app.py new file mode 100644 index 00000000..ec8fb942 --- /dev/null +++ b/viz/server/app.py @@ -0,0 +1,604 @@ +"""Humanize Viz — Flask application. + +Serves the SPA frontend, REST API for session data, and WebSocket +for real-time file change notifications. +""" + +import os +import sys +import json +import argparse +import subprocess +import threading +from flask import Flask, jsonify, send_from_directory, abort +from flask_sock import Sock + +# Add server directory to path +sys.path.insert(0, os.path.dirname(__file__)) +from parser import list_sessions, parse_session, read_plan_file +from analyzer import compute_analytics +from exporter import export_session_markdown +from watcher import SessionWatcher + +app = Flask(__name__, static_folder=None) +sock = Sock(app) + +# Global state +PROJECT_DIR = '.' +STATIC_DIR = '.' +_session_cache = {} +_cache_lock = threading.Lock() +_ws_clients = set() +_ws_lock = threading.Lock() +_watcher = None + + +def _get_rlcr_dir(): + return os.path.join(PROJECT_DIR, '.humanize', 'rlcr') + + +def _get_session_dir(session_id): + d = os.path.join(_get_rlcr_dir(), session_id) + if not os.path.isdir(d): + return None + return d + + +def _get_session(session_id, force_refresh=False): + """Get session data with caching.""" + with _cache_lock: + if not force_refresh and session_id in _session_cache: + return _session_cache[session_id] + + session_dir = _get_session_dir(session_id) + if not session_dir: + return None + + session = parse_session(session_dir) + with _cache_lock: + _session_cache[session_id] = session + return session + + +def _invalidate_cache(session_id=None): + """Invalidate cache for a session or all sessions.""" + with _cache_lock: + if session_id: + _session_cache.pop(session_id, None) + else: + _session_cache.clear() + + +def broadcast_message(message): + """Send a message to all connected WebSocket clients.""" + dead = set() + with _ws_lock: + clients = set(_ws_clients) + + for ws in clients: + try: + ws.send(message) + except Exception: + dead.add(ws) + + if dead: + with _ws_lock: + _ws_clients -= dead + + # Invalidate cache for the affected session + try: + data = json.loads(message) + _invalidate_cache(data.get('session_id')) + except (json.JSONDecodeError, AttributeError): + pass + + +# --- Static file serving --- + +@app.route('/') +def index(): + return send_from_directory(STATIC_DIR, 'index.html') + + +@app.route('/') +def static_files(path): + if path.startswith('api/'): + abort(404) + full_path = os.path.join(STATIC_DIR, path) + if os.path.isfile(full_path): + return send_from_directory(STATIC_DIR, path) + # SPA fallback + return send_from_directory(STATIC_DIR, 'index.html') + + +# --- Health check --- + +@app.route('/api/health') +def health(): + return jsonify({'status': 'ok'}) + + +# --- REST API --- + +@app.route('/api/sessions') +def api_sessions(): + sessions = list_sessions(PROJECT_DIR) + # Return summary-level data (no full round content) + summaries = [] + for s in sessions: + summaries.append({ + 'id': s['id'], + 'status': s['status'], + 'current_round': s['current_round'], + 'max_iterations': s['max_iterations'], + 'plan_file': s['plan_file'], + 'start_branch': s['start_branch'], + 'started_at': s['started_at'], + 'last_verdict': s['last_verdict'], + 'drift_status': s['drift_status'], + 'tasks_done': s['tasks_done'], + 'tasks_total': s['tasks_total'], + 'ac_done': s['ac_done'], + 'ac_total': s['ac_total'], + 'duration_minutes': s.get('duration_minutes'), + }) + return jsonify(summaries) + + +@app.route('/api/sessions/') +def api_session_detail(session_id): + session = _get_session(session_id) + if not session: + abort(404) + return jsonify(session) + + +@app.route('/api/sessions//plan') +def api_session_plan(session_id): + session_dir = _get_session_dir(session_id) + if not session_dir: + abort(404) + plan = read_plan_file(session_dir, PROJECT_DIR) + if plan is None: + abort(404) + return jsonify({'content': plan}) + + +@app.route('/api/sessions//report') +def api_session_report(session_id): + session = _get_session(session_id) + if not session: + abort(404) + report = session.get('methodology_report') + if not report: + abort(404) + return jsonify({'content': report}) + + +@app.route('/api/analytics') +def api_analytics(): + sessions = list_sessions(PROJECT_DIR) + analytics = compute_analytics(sessions) + return jsonify(analytics) + + +def _find_cancel_script(): + """Resolve cancel-rlcr-loop.sh from plugin layout or env.""" + # Check env override first + env_script = os.environ.get('HUMANIZE_CANCEL_SCRIPT', '') + if env_script and os.path.isfile(env_script): + return env_script + + # Sibling path within the same humanize plugin repo (viz/server/../../scripts/) + server_dir = os.path.dirname(os.path.abspath(__file__)) + sibling = os.path.normpath(os.path.join(server_dir, '..', '..', 'scripts', 'cancel-rlcr-loop.sh')) + if os.path.isfile(sibling): + return sibling + + # Search standard plugin cache locations + search_paths = [ + os.path.expanduser('~/.claude/plugins/cache/humania/humanize'), + os.path.expanduser('~/.claude/plugins/marketplaces/humania'), + ] + for base in search_paths: + if not os.path.isdir(base): + continue + for entry in sorted(os.listdir(base), reverse=True): + candidate = os.path.join(base, entry, 'scripts', 'cancel-rlcr-loop.sh') + if os.path.isfile(candidate): + return candidate + candidate = os.path.join(base, 'scripts', 'cancel-rlcr-loop.sh') + if os.path.isfile(candidate): + return candidate + + return None + + +@app.route('/api/sessions//cancel', methods=['POST']) +def api_cancel_session(session_id): + session = _get_session(session_id) + if not session: + abort(404) + if session['status'] != 'active': + return jsonify({'error': 'Session is not active'}), 400 + + cancel_script = _find_cancel_script() + if not cancel_script: + return jsonify({'error': 'Cancel script not found. Ensure humanize plugin is installed.'}), 500 + + try: + subprocess.run([cancel_script], cwd=PROJECT_DIR, timeout=30, check=True) + _invalidate_cache(session_id) + return jsonify({'status': 'cancelled'}) + except subprocess.SubprocessError as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/sessions//export', methods=['POST']) +def api_export_session(session_id): + session = _get_session(session_id) + if not session: + abort(404) + markdown = export_session_markdown(session) + return jsonify({'content': markdown, 'filename': f'rlcr-report-{session_id}.md'}) + + +import re as _re + + +_FORBIDDEN_CATEGORIES = [ + ('path_token', _re.compile(r'[/\\]\w+\.\w{1,4}\b')), + ('path_token', _re.compile(r'\b\w+/\w+/\w+')), + ('qualified_name', _re.compile(r'\b\w+::\w+')), + ('qualified_name', _re.compile(r'\b\w+\.\w+\.\w+\(')), + ('git_hash', _re.compile(r'\b[a-f0-9]{7,40}\b')), + ('branch_name', _re.compile(r'\b(?:feat|fix|hotfix|release|bugfix)/\w+')), + ('branch_name', _re.compile(r'\bmain|master|develop\b')), + ('code_definition', _re.compile(r'\bdef \w+|function \w+|class \w+')), + ('import_statement', _re.compile(r'\b(?:import|require|from)\s+\w+')), + ('code_fence', _re.compile(r'```')), + ('identifier', _re.compile(r'\b\w+_\w+_\w+\b')), + ('identifier', _re.compile(r'\b[a-z]+[A-Z]\w+\b')), + ('stack_trace', _re.compile(r'\bTraceback \(most recent')), + ('stack_trace', _re.compile(r'\bFile ".+", line \d+')), + ('error_pattern', _re.compile(r'\b(?:Error|Exception|Panic|SIGSEGV|SIGABRT)\b')), + ('stack_trace', _re.compile(r'at \w+\.\w+\(.*:\d+:\d+\)')), + ('external_url', _re.compile(r'https?://(?!github\.com/humania)')), + ('local_endpoint', _re.compile(r'\b(?:localhost|127\.0\.0\.1):\d+')), +] + + +def _scan_for_forbidden_tokens(text): + """Return dict of {category: count} for forbidden patterns found in text. + Never returns the matched strings themselves to prevent leakage.""" + violations = {} + for category, pattern in _FORBIDDEN_CATEGORIES: + matches = pattern.findall(text) + if matches: + violations[category] = violations.get(category, 0) + len(matches) + return violations + + +def _is_english_only(text): + """Check that text is predominantly ASCII/English (>95% ASCII chars).""" + if not text: + return True + ascii_count = sum(1 for c in text if ord(c) < 128) + return (ascii_count / len(text)) > 0.95 + + +# Constrained methodology taxonomy — observations are classified into +# these generic categories. Only the category label and a generic phrasing +# are emitted into the issue; no report prose passes through. +_METHODOLOGY_CATEGORIES = { + 'iteration_efficiency': 'Iteration efficiency pattern observed: rounds showed uneven productivity distribution.', + 'feedback_loop': 'Feedback loop quality issue: reviewer-implementer communication could be improved.', + 'stagnation': 'Stagnation pattern detected: consecutive rounds showed limited forward progress.', + 'review_effectiveness': 'Review effectiveness concern: review feedback did not consistently drive improvements.', + 'plan_execution': 'Plan-execution alignment gap: implementation drifted from the original plan structure.', + 'verification_gap': 'Verification scope issue: implementer verification did not match reviewer expectations.', + 'phase_transition': 'Phase transition pattern: the boundary between implementation and review phases was unclear.', + 'scope_management': 'Scope management observation: work expanded or contracted relative to plan boundaries.', + 'general': 'General methodology observation noted.', +} + +_CATEGORY_KEYWORDS = { + 'iteration_efficiency': ['efficiency', 'productive', 'unproductive', 'round count', 'per-round output', 'diminish'], + 'feedback_loop': ['feedback', 'communication', 'reviewer', 'implementer', 'round-trip'], + 'stagnation': ['stagnation', 'stall', 'circle', 'repeat', 'no progress', 'same issue'], + 'review_effectiveness': ['false positive', 'review quality', 'missed issue', 'review catch'], + 'plan_execution': ['plan drift', 'alignment', 'deviat', 'scope change', 'off-plan'], + 'verification_gap': ['verification', 'insufficient test', 'too narrow', 'missed check', 'universal quantifier'], + 'phase_transition': ['phase transition', 'review phase', 'implementation phase', 'polishing', 'two-phase'], + 'scope_management': ['scope', 'over-engineer', 'under-deliver', 'bloat', 'defer'], +} + + +def _classify_observation(text): + """Classify a report observation into a methodology category.""" + lower = text.lower() + best_cat = 'general' + best_score = 0 + for cat, keywords in _CATEGORY_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in lower) + if score > best_score: + best_score = score + best_cat = cat + return best_cat + + +def _build_sanitized_issue(session): + """Build a sanitized GitHub issue payload following issue #62 format. + + Uses constrained methodology taxonomy — no report prose passes through. + Returns dict with 'title', 'body', and 'warnings' keys, or None if no report. + Warnings contain only category names and counts, never matched strings. + """ + report_obj = session.get('methodology_report', {}) + # Prefer English report; fall back to Chinese + report = (report_obj or {}).get('en') or (report_obj or {}).get('zh') or '' + if not report: + return None + + # Source diagnostics (informational only — do NOT gate outbound) + source_diagnostics = {} + if not _is_english_only(report): + source_diagnostics['non_english'] = 1 + + # Extract raw observations and suggestions from report structure + raw_observations = [] + raw_suggestions = [] + current_section = None + + for line in report.split('\n'): + stripped = line.strip() + if stripped.lower().startswith('## observation') or stripped.lower().startswith('## finding'): + current_section = 'observations' + continue + elif stripped.lower().startswith('## suggest'): + current_section = 'suggestions' + continue + elif stripped.startswith('## '): + current_section = stripped[3:].strip().lower() + continue + + if current_section == 'observations' and stripped.startswith(('- ', '* ', '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')): + raw_observations.append(stripped.lstrip('-* 0123456789.').strip()) + elif current_section == 'suggestions' and stripped.startswith('|') and not stripped.startswith('|---') and not stripped.startswith('| #'): + cols = [c.strip() for c in stripped.split('|')[1:-1]] + if len(cols) >= 2: + raw_suggestions.append(cols) + + if not raw_observations: + for line in report.split('\n'): + stripped = line.strip() + if stripped and not stripped.startswith('#') and not stripped.startswith('|') and not stripped.startswith('---'): + raw_observations.append(stripped) + + # Log source-level findings as diagnostics (not blocking) + for obs in raw_observations: + violations = _scan_for_forbidden_tokens(obs) + for cat, count in violations.items(): + source_diagnostics[cat] = source_diagnostics.get(cat, 0) + count + + # Classify observations into methodology categories (no prose passes through) + category_counts = {} + for obs in raw_observations: + category = _classify_observation(obs) + category_counts[category] = category_counts.get(category, 0) + 1 + + # Classify suggestions into methodology categories (no raw text passes through) + suggestion_categories = {} + for cols in raw_suggestions: + combined = ' '.join(cols) + cat = _classify_observation(combined) + suggestion_categories[cat] = suggestion_categories.get(cat, 0) + 1 + + # Build title from dominant category (no report text) + dominant_cat = max(category_counts, key=category_counts.get) if category_counts else 'general' + title = f"RLCR: {dominant_cat.replace('_', ' ').capitalize()} pattern identified" + + # Build issue #62 body using ONLY taxonomy-derived phrasing + s = session + body_lines = [ + '## Context\n', + f'A {s["current_round"]}-round RLCR session ended with status: {s["status"]}.', + ] + if s.get('ac_total', 0) > 0: + body_lines.append(f'Acceptance criteria: {s["ac_done"]}/{s["ac_total"]} verified.') + body_lines.append('') + + body_lines.append('## Observations\n') + for i, (cat, count) in enumerate(sorted(category_counts.items(), key=lambda x: -x[1]), 1): + generic_text = _METHODOLOGY_CATEGORIES.get(cat, _METHODOLOGY_CATEGORIES['general']) + body_lines.append(f'{i}. **{cat.replace("_", " ").capitalize()}** ({count}x): {generic_text}') + + body_lines.append('') + body_lines.append('## Suggested Improvements\n') + body_lines.append('| # | Suggestion | Mechanism |') + body_lines.append('|---|-----------|-----------|') + if suggestion_categories: + for i, (cat, count) in enumerate(sorted(suggestion_categories.items(), key=lambda x: -x[1]), 1): + generic_suggestion = f'Improve {cat.replace("_", " ")} practices' + mechanism = f'Apply targeted {cat.replace("_", " ")} methodology adjustments ({count} suggestion(s) in this area)' + body_lines.append(f'| {i} | {generic_suggestion} | {mechanism} |') + else: + body_lines.append('| - | No specific suggestions identified | - |') + + body_lines.append('') + body_lines.append('## Quantitative Summary\n') + body_lines.append('| Metric | Value |') + body_lines.append('|--------|-------|') + body_lines.append(f'| Total rounds | {s["current_round"]} |') + body_lines.append(f'| Exit reason | {s["status"].capitalize()} |') + if s.get('ac_total', 0) > 0: + rate = round(s['ac_done'] / s['ac_total'] * 100) if s['ac_total'] > 0 else 0 + body_lines.append(f'| AC count | {s["ac_total"]} |') + body_lines.append(f'| Completion rate | {rate}% |') + body_lines.append(f'| Observation categories | {len(category_counts)} |') + body_lines.append(f'| Total observations | {sum(category_counts.values())} |') + + body = '\n'.join(body_lines) + + # OUTBOUND VALIDATION: only the final generated title/body determine + # whether the payload is safe to send. Source-report findings are + # informational and do NOT gate the outbound path. + outbound_warnings = {} + + final_violations = _scan_for_forbidden_tokens(body) + for cat, count in final_violations.items(): + outbound_warnings[cat] = outbound_warnings.get(cat, 0) + count + + title_violations = _scan_for_forbidden_tokens(title) + for cat, count in title_violations.items(): + outbound_warnings[cat] = outbound_warnings.get(cat, 0) + count + + if not _is_english_only(body): + outbound_warnings['non_english'] = 1 + + return { + 'title': title, + 'body': body, + 'warnings': outbound_warnings, + 'source_diagnostics': source_diagnostics, + } + + +@app.route('/api/sessions//sanitized-issue') +def api_sanitized_issue(session_id): + session = _get_session(session_id) + if not session: + abort(404) + payload = _build_sanitized_issue(session) + if not payload: + abort(404) + + # Outbound gate: only block if the FINAL generated payload has warnings + if payload.get('warnings'): + return jsonify({ + 'title': payload['title'], + 'body': '[REDACTED — outbound payload failed validation.]', + 'warnings': payload['warnings'], + 'source_diagnostics': payload.get('source_diagnostics', {}), + 'requires_review': True, + }) + + # Clean payload — include source diagnostics as informational + result = { + 'title': payload['title'], + 'body': payload['body'], + 'warnings': {}, + 'source_diagnostics': payload.get('source_diagnostics', {}), + } + return jsonify(result) + + +@app.route('/api/sessions//github-issue', methods=['POST']) +def api_github_issue(session_id): + session = _get_session(session_id) + if not session: + abort(404) + + payload = _build_sanitized_issue(session) + if not payload: + return jsonify({'error': 'No methodology report available'}), 400 + + # Block submission and redact body when sanitization warnings exist + if payload.get('warnings'): + return jsonify({ + 'error': 'Sanitization check failed. Review the methodology report manually and remove project-specific content before sending.', + 'warnings': payload['warnings'], + 'manual': False, + }), 400 + + title = payload['title'] + body = payload['body'] + + # Check if gh is available + try: + subprocess.run(['gh', '--version'], capture_output=True, timeout=5, check=True) + except (subprocess.SubprocessError, FileNotFoundError): + return jsonify({ + 'error': 'gh CLI not available', + 'title': title, + 'body': body, + 'manual': True, + }), 400 + + try: + result = subprocess.run( + ['gh', 'issue', 'create', '--repo', 'humania-org/humanize', + '--title', title, '--body', body], + capture_output=True, text=True, timeout=30, check=True, cwd=PROJECT_DIR, + ) + url = result.stdout.strip() + return jsonify({'status': 'created', 'url': url}) + except subprocess.SubprocessError as e: + return jsonify({ + 'error': str(e), + 'title': title, + 'body': body, + 'manual': True, + }), 500 + + +# --- WebSocket --- + +@sock.route('/ws') +def websocket(ws): + with _ws_lock: + _ws_clients.add(ws) + try: + while True: + data = ws.receive(timeout=60) + if data is None: + continue + try: + msg = json.loads(data) + if msg.get('type') == 'cancel_session': + sid = msg.get('session_id', '') + if sid: + session = _get_session(sid) + if session and session['status'] == 'active': + cancel_script = _find_cancel_script() + if cancel_script: + subprocess.run([cancel_script], cwd=PROJECT_DIR, timeout=30) + _invalidate_cache(sid) + except (json.JSONDecodeError, KeyError): + pass + except Exception: + pass + finally: + with _ws_lock: + _ws_clients.discard(ws) + + +# --- Main --- + +def main(): + parser = argparse.ArgumentParser(description='Humanize Viz Dashboard Server') + parser.add_argument('--port', type=int, default=18000) + parser.add_argument('--project', type=str, default='.') + parser.add_argument('--static', type=str, default='.') + args = parser.parse_args() + + global PROJECT_DIR, STATIC_DIR, _watcher + PROJECT_DIR = os.path.abspath(args.project) + STATIC_DIR = os.path.abspath(args.static) + + # Start file watcher + _watcher = SessionWatcher(PROJECT_DIR, broadcast_message) + _watcher.start() + + # Pre-populate cache + list_sessions(PROJECT_DIR) + + print(f"Humanize Viz server starting on http://localhost:{args.port}") + print(f"Project: {PROJECT_DIR}") + print(f"Static: {STATIC_DIR}") + + app.run(host='127.0.0.1', port=args.port, debug=False) + + +if __name__ == '__main__': + main() diff --git a/viz/server/exporter.py b/viz/server/exporter.py new file mode 100644 index 00000000..03e1461b --- /dev/null +++ b/viz/server/exporter.py @@ -0,0 +1,85 @@ +"""Export RLCR session data as Markdown reports.""" + + +def _resolve_content(value, lang='en'): + """Extract string content from a bilingual {zh, en} dict or plain string.""" + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, dict): + return value.get(lang) or value.get('en') or value.get('zh') + return str(value) + + +def export_session_markdown(session, lang='en'): + """Generate a structured Markdown report for a session.""" + lines = [] + sid = session['id'] + lines.append(f"# RLCR Session Report — {sid}\n") + + # Overview table + lines.append("## Overview\n") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") + lines.append(f"| Status | {session['status'].capitalize()} |") + lines.append(f"| Rounds | {session['current_round']} |") + lines.append(f"| Plan | {session.get('plan_file', 'N/A')} |") + lines.append(f"| Branch | {session.get('start_branch', 'N/A')} |") + lines.append(f"| Started | {session.get('started_at', 'N/A')} |") + lines.append(f"| Codex Model | {session.get('codex_model', 'N/A')} |") + lines.append(f"| Last Verdict | {session.get('last_verdict', 'N/A')} |") + + ac_total = session.get('ac_total', 0) + ac_done = session.get('ac_done', 0) + if ac_total > 0: + lines.append(f"| AC Completion | {ac_done}/{ac_total} ({round(ac_done/ac_total*100)}%) |") + lines.append("") + + # Round history + if session.get('rounds'): + lines.append("## Round History\n") + for r in session['rounds']: + rn = r['number'] + lines.append(f"### Round {rn}\n") + lines.append(f"**Phase**: {r.get('phase', 'N/A')}") + lines.append(f"**Verdict**: {r.get('verdict', 'N/A')}") + if r.get('duration_minutes'): + lines.append(f"**Duration**: {r['duration_minutes']} min") + if r.get('bitlesson_delta') and r['bitlesson_delta'] != 'none': + lines.append(f"**BitLesson**: {r['bitlesson_delta']}") + lines.append("") + + summary_text = _resolve_content(r.get('summary'), lang) + if summary_text: + lines.append("#### Summary\n") + lines.append(summary_text) + lines.append("") + + review_text = _resolve_content(r.get('review_result'), lang) + if review_text: + lines.append("#### Codex Review\n") + lines.append(review_text) + lines.append("") + + # Goal Tracker + gt = session.get('goal_tracker') + if gt: + lines.append("## Goal Tracker\n") + lines.append(f"**Ultimate Goal**: {gt.get('ultimate_goal', 'N/A')}\n") + + if gt.get('acceptance_criteria'): + lines.append("### Acceptance Criteria\n") + for ac in gt['acceptance_criteria']: + status_icon = {'completed': '\u2713', 'in_progress': '\u25C9', 'pending': '\u25CB'}.get(ac['status'], '?') + lines.append(f"- {status_icon} **{ac['id']}**: {ac['description']}") + lines.append("") + + # Methodology analysis + report_text = _resolve_content(session.get('methodology_report'), lang) + if report_text: + lines.append("## Methodology Analysis\n") + lines.append(report_text) + lines.append("") + + return '\n'.join(lines) diff --git a/viz/server/parser.py b/viz/server/parser.py new file mode 100644 index 00000000..25a28fc7 --- /dev/null +++ b/viz/server/parser.py @@ -0,0 +1,434 @@ +"""Parse RLCR session data from .humanize/rlcr/ directories. + +Reads state.md (YAML frontmatter), goal-tracker.md, round summaries, +review results, and methodology reports into structured Python dicts. +""" + +import logging +import os +import re +import yaml +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def parse_yaml_frontmatter(filepath): + """Extract YAML frontmatter from a Markdown file with --- delimiters.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return {}, '' + + if not content.startswith('---'): + return {}, content + + parts = content.split('---', 2) + if len(parts) < 3: + return {}, content + + try: + meta = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + meta = {} + + body = parts[2].strip() + return meta, body + + +def detect_session_status(session_dir): + """Determine session status from terminal state files.""" + terminal_states = { + 'complete-state.md': 'complete', + 'cancel-state.md': 'cancel', + 'stop-state.md': 'stop', + 'maxiter-state.md': 'maxiter', + 'unexpected-state.md': 'unexpected', + 'methodology-analysis-state.md': 'analyzing', + 'finalize-state.md': 'finalizing', + } + for filename, status in terminal_states.items(): + if os.path.exists(os.path.join(session_dir, filename)): + return status + + if os.path.exists(os.path.join(session_dir, 'state.md')): + return 'active' + + return 'unknown' + + +def parse_state(session_dir): + """Parse state.md or any *-state.md file in the session directory.""" + state_file = os.path.join(session_dir, 'state.md') + if not os.path.exists(state_file): + for f in os.listdir(session_dir): + if f.endswith('-state.md'): + state_file = os.path.join(session_dir, f) + break + + meta, _ = parse_yaml_frontmatter(state_file) + return meta + + +def parse_goal_tracker(session_dir): + """Parse goal-tracker.md into structured data.""" + filepath = os.path.join(session_dir, 'goal-tracker.md') + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + result = { + 'ultimate_goal': '', + 'acceptance_criteria': [], + 'active_tasks': [], + 'completed_verified': [], + } + + # Extract ultimate goal + goal_match = re.search(r'### Ultimate Goal\s*\n(.*?)(?=\n###|\n---|\Z)', content, re.DOTALL) + if goal_match: + result['ultimate_goal'] = goal_match.group(1).strip() + + # Parse Completed and Verified table + completed_acs = set() + cv_section = re.search(r'### Completed and Verified.*?\n\|.*?\n\|[-|]+\n(.*?)(?=\n###|\Z)', content, re.DOTALL) + if cv_section: + for line in cv_section.group(1).strip().split('\n'): + if not line.strip() or not line.strip().startswith('|'): + continue + cols = [c.strip() for c in line.split('|')[1:-1]] + if len(cols) >= 4: + completed_acs.add(cols[0]) + result['completed_verified'].append({ + 'ac': cols[0], + 'task': cols[1], + 'completed_round': cols[2], + 'evidence': cols[3] if len(cols) > 3 else '', + }) + + # Extract acceptance criteria with status from parsed tables + ac_pattern = re.compile(r'- (AC-\d+): (.+?)(?=\n- AC-|\n---|\n###|\Z)', re.DOTALL) + for match in ac_pattern.finditer(content): + ac_id = match.group(1) + desc = match.group(2).strip().split('\n')[0] + status = 'completed' if ac_id in completed_acs else 'pending' + result['acceptance_criteria'].append({ + 'id': ac_id, + 'description': desc, + 'status': status, + }) + + # Check active tasks for in_progress status to refine AC status + active_section = re.search(r'#### Active Tasks.*?\n\|.*?\n\|[-|]+\n(.*?)(?=\n###|\Z)', content, re.DOTALL) + in_progress_acs = set() + if active_section: + for line in active_section.group(1).strip().split('\n'): + if not line.strip() or not line.strip().startswith('|'): + continue + cols = [c.strip() for c in line.split('|')[1:-1]] + if len(cols) >= 3: + task_status = cols[2].lower() + target_acs = cols[1] + result['active_tasks'].append({ + 'task': cols[0], + 'target_ac': target_acs, + 'status': cols[2], + 'notes': cols[-1] if len(cols) > 4 else '', + }) + if task_status in ('in_progress', 'implemented', 'needs_revision'): + for ac_ref in re.findall(r'AC-\d+', target_acs): + in_progress_acs.add(ac_ref) + + # Update AC status: in_progress if any active task references it + for ac in result['acceptance_criteria']: + if ac['status'] == 'pending' and ac['id'] in in_progress_acs: + ac['status'] = 'in_progress' + + return result + + +def _detect_language(text): + """Detect if text is primarily Chinese or English based on character ranges.""" + if not text: + return 'en' + cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3000' <= c <= '\u303f') + return 'zh' if cjk_count > len(text) * 0.05 else 'en' + + +def _to_bilingual(content): + """Wrap content string into {zh, en} structure based on detected language.""" + if content is None: + return {'zh': None, 'en': None} + lang = _detect_language(content) + return {'zh': content if lang == 'zh' else None, 'en': content if lang == 'en' else None} + + +def _extract_task_progress(content): + """Extract task completion count from round summary content. + + Returns an integer count only when an explicit "N/M tasks" pattern is found. + Returns None when no reliable data is extractable — callers should treat + None as "unknown" and display accordingly. + """ + if not content: + return None + + # Only trust explicit "X/Y tasks" or "X of Y tasks" patterns + m = re.search(r'(\d+)\s*/\s*(\d+)\s*(?:tasks?|coding tasks?)', content, re.IGNORECASE) + if m: + return int(m.group(1)) + + m = re.search(r'(\d+)\s+of\s+(\d+)\s+(?:tasks?|coding tasks?)', content, re.IGNORECASE) + if m: + return int(m.group(1)) + + return None + + +def parse_round_summary(filepath): + """Parse a round-N-summary.md file.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + bitlesson_delta = 'none' + bl_match = re.search(r'Action:\s*(none|add|update)', content, re.IGNORECASE) + if bl_match: + bitlesson_delta = bl_match.group(1).lower() + + task_progress = _extract_task_progress(content) + + return { + 'content': _to_bilingual(content), + 'bitlesson_delta': bitlesson_delta, + 'task_progress': task_progress, + 'mtime': os.path.getmtime(filepath), + } + + +def parse_review_result(filepath): + """Parse a round-N-review-result.md file.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + verdict = 'unknown' + if 'COMPLETE' in content: + verdict = 'complete' + else: + for v in ('advanced', 'stalled', 'regressed'): + if v in content.lower(): + verdict = v + break + + p_issues = {} + for match in re.finditer(r'\[P(\d)\]', content): + level = f'P{match.group(1)}' + p_issues[level] = p_issues.get(level, 0) + 1 + + return { + 'content': _to_bilingual(content), + 'verdict': verdict, + 'p_issues': p_issues, + 'mtime': os.path.getmtime(filepath), + } + + +def parse_session(session_dir): + """Parse a complete RLCR session directory into a structured dict.""" + session_id = os.path.basename(session_dir) + status = detect_session_status(session_dir) + state = parse_state(session_dir) + goal_tracker = parse_goal_tracker(session_dir) + + current_round = state.get('current_round', 0) + + # Build rounds from canonical indices 0..current_round + rounds = [] + prev_mtime = None + for rn in range(current_round + 1): + summary_file = os.path.join(session_dir, f'round-{rn}-summary.md') + review_file = os.path.join(session_dir, f'round-{rn}-review-result.md') + + summary = parse_round_summary(summary_file) + review = parse_review_result(review_file) + + # Duration from consecutive summary timestamps + duration_minutes = None + if summary and prev_mtime is not None: + duration_minutes = round((summary['mtime'] - prev_mtime) / 60, 1) + if summary: + prev_mtime = summary['mtime'] + + # Per-round task progress: only from explicit patterns in this round's summary + task_progress = summary.get('task_progress') if summary else None + + rounds.append({ + 'number': rn, + 'phase': _determine_phase(session_dir, rn, status), + 'summary': summary['content'] if summary else {'zh': None, 'en': None}, + 'review_result': review['content'] if review else {'zh': None, 'en': None}, + 'verdict': review['verdict'] if review else 'unknown', + 'bitlesson_delta': summary['bitlesson_delta'] if summary else 'none', + 'duration_minutes': duration_minutes, + 'p_issues': review['p_issues'] if review else {}, + 'task_progress': task_progress, + }) + + # Task/AC progress from goal tracker + tasks_done = 0 + tasks_total = 0 + ac_done = 0 + ac_total = 0 + if goal_tracker: + tasks_total = len(goal_tracker['active_tasks']) + len(goal_tracker['completed_verified']) + tasks_done = len(goal_tracker['completed_verified']) + ac_total = len(goal_tracker['acceptance_criteria']) + ac_done = sum(1 for ac in goal_tracker['acceptance_criteria'] if ac['status'] == 'completed') + + # Methodology report (bilingual) + report_file = os.path.join(session_dir, 'methodology-analysis-report.md') + methodology_report = {'zh': None, 'en': None} + if os.path.exists(report_file): + try: + with open(report_file, 'r', encoding='utf-8') as f: + raw_report = f.read() + methodology_report = _to_bilingual(raw_report) + except (PermissionError, OSError): + pass + + # Compute session duration from first/last round timestamps + session_duration_minutes = None + if len(rounds) >= 2: + first_mtime = None + last_mtime = None + for rn in range(current_round + 1): + sf = os.path.join(session_dir, f'round-{rn}-summary.md') + if os.path.exists(sf): + mt = os.path.getmtime(sf) + if first_mtime is None: + first_mtime = mt + last_mtime = mt + if first_mtime and last_mtime and last_mtime > first_mtime: + session_duration_minutes = round((last_mtime - first_mtime) / 60, 1) + + # started_at + started_at = state.get('started_at', '') + if not started_at: + try: + dt = datetime.strptime(session_id, '%Y-%m-%d_%H-%M-%S') + started_at = dt.isoformat() + 'Z' + except ValueError: + started_at = '' + + return { + 'id': session_id, + 'status': status, + 'current_round': current_round, + 'max_iterations': state.get('max_iterations', 42), + 'plan_file': state.get('plan_file', ''), + 'start_branch': state.get('start_branch', ''), + 'base_branch': state.get('base_branch', ''), + 'started_at': started_at, + 'codex_model': state.get('codex_model', ''), + 'codex_effort': state.get('codex_effort', ''), + 'last_verdict': rounds[-1]['verdict'] if rounds else 'unknown', + 'drift_status': state.get('drift_status', 'normal'), + 'rounds': rounds, + 'goal_tracker': goal_tracker, + 'methodology_report': methodology_report, + 'tasks_done': tasks_done, + 'tasks_total': tasks_total, + 'ac_done': ac_done, + 'ac_total': ac_total, + 'duration_minutes': session_duration_minutes, + } + + +def _determine_phase(session_dir, round_num, session_status): + """Determine the phase of a specific round.""" + review_started_file = os.path.join(session_dir, '.review-phase-started') + if os.path.exists(review_started_file): + try: + with open(review_started_file, 'r') as f: + content = f.read() + match = re.search(r'build_finish_round=(\d+)', content) + if match: + build_round = int(match.group(1)) + if round_num > build_round: + return 'code_review' + except (PermissionError, OSError): + pass + + if session_status == 'finalizing': + return 'finalize' + + return 'implementation' + + +def is_valid_session(session_dir): + """Check if a session directory has minimum required files.""" + has_state = os.path.exists(os.path.join(session_dir, 'state.md')) + has_terminal = any( + f.endswith('-state.md') and f != 'state.md' + for f in os.listdir(session_dir) + if os.path.isfile(os.path.join(session_dir, f)) + ) + return has_state or has_terminal + + +def list_sessions(project_dir): + """List all RLCR sessions in a project directory.""" + rlcr_dir = os.path.join(project_dir, '.humanize', 'rlcr') + if not os.path.isdir(rlcr_dir): + return [] + + sessions = [] + for entry in sorted(os.listdir(rlcr_dir), reverse=True): + session_dir = os.path.join(rlcr_dir, entry) + if not os.path.isdir(session_dir): + continue + + if not is_valid_session(session_dir): + logger.warning("Skipping malformed session directory: %s (no state.md or terminal state file)", entry) + continue + + try: + session = parse_session(session_dir) + sessions.append(session) + except Exception as e: + logger.warning("Failed to parse session %s: %s", entry, e) + continue + + return sessions + + +def read_plan_file(session_dir, project_dir): + """Read the plan file for a session.""" + state = parse_state(session_dir) + plan_path = state.get('plan_file', '') + if not plan_path: + backup = os.path.join(session_dir, 'plan.md') + if os.path.exists(backup): + with open(backup, 'r', encoding='utf-8') as f: + return f.read() + return None + + full_path = os.path.join(project_dir, plan_path) + if os.path.exists(full_path): + with open(full_path, 'r', encoding='utf-8') as f: + return f.read() + + backup = os.path.join(session_dir, 'plan.md') + if os.path.exists(backup): + with open(backup, 'r', encoding='utf-8') as f: + return f.read() + + return None diff --git a/viz/server/requirements.txt b/viz/server/requirements.txt new file mode 100644 index 00000000..d67e68eb --- /dev/null +++ b/viz/server/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0,<4.0 +flask-sock>=0.7,<1.0 +watchdog>=4.0,<5.0 +pyyaml>=6.0,<7.0 +markdown>=3.5,<4.0 diff --git a/viz/server/watcher.py b/viz/server/watcher.py new file mode 100644 index 00000000..91a159f2 --- /dev/null +++ b/viz/server/watcher.py @@ -0,0 +1,112 @@ +"""File system watcher for RLCR session directories. + +Uses watchdog to monitor .humanize/rlcr/ and pushes WebSocket events +when session files change. Events are debounced (500ms) to avoid +spamming during rapid consecutive writes. +""" + +import os +import re +import json +import time +import threading +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + + +class RLCREventHandler(FileSystemEventHandler): + """Maps file changes to WebSocket event types.""" + + def __init__(self, rlcr_dir, broadcast_fn): + super().__init__() + self.rlcr_dir = rlcr_dir + self.broadcast = broadcast_fn + self._pending = {} + self._lock = threading.Lock() + self._timer = None + self.debounce_ms = 500 + + def on_any_event(self, event): + src = str(event.src_path) + + if event.is_directory and event.event_type == 'created': + rel = os.path.relpath(src, self.rlcr_dir) + if '/' not in rel and '\\' not in rel: + self._schedule_event('session_created', rel) + return + + if event.is_directory: + return + + rel = os.path.relpath(src, self.rlcr_dir) + parts = rel.replace('\\', '/').split('/') + + if len(parts) < 2: + return + + session_id = parts[0] + filename = parts[1] + + if filename == 'state.md': + self._schedule_event('session_updated', session_id) + elif filename == 'goal-tracker.md': + self._schedule_event('session_updated', session_id) + elif re.match(r'round-\d+-summary\.md$', filename): + self._schedule_event('round_added', session_id) + elif re.match(r'round-\d+-review-result\.md$', filename): + self._schedule_event('session_updated', session_id) + elif filename.endswith('-state.md') and filename != 'state.md': + self._schedule_event('session_finished', session_id) + + def _schedule_event(self, event_type, session_id): + """Debounce events: accumulate for 500ms before broadcasting.""" + key = f"{event_type}:{session_id}" + with self._lock: + self._pending[key] = { + 'type': event_type, + 'session_id': session_id, + 'time': time.time(), + } + self._reset_timer() + + def _reset_timer(self): + if self._timer: + self._timer.cancel() + self._timer = threading.Timer(self.debounce_ms / 1000.0, self._flush) + self._timer.daemon = True + self._timer.start() + + def _flush(self): + with self._lock: + events = list(self._pending.values()) + self._pending.clear() + + for event in events: + self.broadcast(json.dumps({ + 'type': event['type'], + 'session_id': event['session_id'], + })) + + +class SessionWatcher: + """Manages the watchdog observer for RLCR directories.""" + + def __init__(self, project_dir, broadcast_fn): + self.rlcr_dir = os.path.join(project_dir, '.humanize', 'rlcr') + self.broadcast = broadcast_fn + self.observer = None + + def start(self): + if not os.path.isdir(self.rlcr_dir): + os.makedirs(self.rlcr_dir, exist_ok=True) + + handler = RLCREventHandler(self.rlcr_dir, self.broadcast) + self.observer = Observer() + self.observer.schedule(handler, self.rlcr_dir, recursive=True) + self.observer.daemon = True + self.observer.start() + + def stop(self): + if self.observer: + self.observer.stop() + self.observer.join(timeout=5) diff --git a/viz/static/css/layout.css b/viz/static/css/layout.css new file mode 100644 index 00000000..7a502c24 --- /dev/null +++ b/viz/static/css/layout.css @@ -0,0 +1,860 @@ +/* ─── Topbar ─── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + height: 52px; + background: var(--bg-1); + border-bottom: 1px solid var(--border-0); + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(12px); +} + +.topbar-left { display: flex; align-items: center; gap: var(--space-3); } + +.topbar-logo { + display: flex; + align-items: center; + gap: var(--space-2); +} +.logo-mark { + color: var(--accent); + font-size: 1.1rem; +} +.logo-text { + font-family: var(--font-display); + font-weight: 800; + font-size: 0.95rem; + letter-spacing: -0.03em; + color: var(--text-0); +} + +.topbar-back { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--text-2); + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: color var(--duration-fast); + margin-right: var(--space-2); +} +.topbar-back:hover { color: var(--text-0); } + +.topbar-title { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-3); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-right { display: flex; align-items: center; gap: var(--space-2); } + +.topbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--text-2); + cursor: pointer; + font-size: 1rem; + transition: all var(--duration-fast); +} +.topbar-btn:hover { background: var(--bg-2); color: var(--text-0); border-color: var(--border-1); } + +.topbar-link { + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-2); + padding: 6px 14px; + border-radius: var(--radius-sm); + transition: all var(--duration-fast); + letter-spacing: 0.01em; +} +.topbar-link:hover { background: var(--bg-2); color: var(--text-0); } + +.lang-toggle { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +/* ─── Main Content ─── */ +.page { + padding: var(--space-8) var(--space-6); + max-width: 1280px; + margin: 0 auto; + animation: fade-up var(--duration-slow) var(--ease-out); +} + +/* ─── Section Headers ─── */ +.section-label { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-5); + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-3); +} +.section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-0); +} + +/* ─── Session Cards ─── */ +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-5); + margin-bottom: var(--space-10); +} + +.session-card { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5) var(--space-5) var(--space-4); + cursor: pointer; + transition: all var(--duration-base) var(--ease-out); + position: relative; + overflow: hidden; +} +.session-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transition: opacity var(--duration-base); +} +.session-card:hover { + border-color: var(--border-2); + transform: translateY(-3px); + box-shadow: var(--shadow-md), var(--shadow-glow); +} +.session-card:hover::before { opacity: 1; } + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} +.card-round-tag { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-2); +} + +.card-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-2) var(--space-5); + font-size: 0.82rem; + margin-bottom: var(--space-3); +} + +.card-field-label { + color: var(--text-3); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-family: var(--font-display); + font-weight: 600; +} +.card-field-value { + color: var(--text-1); + font-weight: 500; +} + +.card-foot { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: var(--space-3); + border-top: 1px solid var(--border-0); + font-size: 0.75rem; + color: var(--text-3); +} + +/* ─── Pipeline Viewport (zoom/pan canvas) ─── */ +.pipeline-container { + width: 100%; + height: 100%; +} + +.pl-viewport { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + cursor: grab; +} +.pl-viewport:active { cursor: grabbing; } + +.pl-controls { + position: absolute; + top: var(--space-3); + right: var(--space-3); + display: flex; + flex-direction: column; + gap: 2px; + z-index: 10; +} + +.pl-ctrl-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border-1); + border-radius: var(--radius-sm); + background: var(--bg-1); + color: var(--text-1); + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--duration-fast); + font-family: var(--font-display); +} +.pl-ctrl-btn:hover { background: var(--bg-3); color: var(--text-0); border-color: var(--accent); } + +.pl-canvas { + position: relative; + transform-origin: 0 0; + transition: transform 80ms ease-out; +} + +.pl-svg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +/* ─── Pipeline Nodes (absolute positioned) ─── */ +.pl-node { + position: absolute; + background: var(--bg-1); + border: 2px solid var(--border-1); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--duration-base) var(--ease-out), + box-shadow var(--duration-base) var(--ease-out); + overflow: hidden; + z-index: 1; +} +.pl-node:hover { + border-color: var(--border-2); + box-shadow: var(--shadow-md); + z-index: 2; +} +.pl-node.expanded { + width: 480px !important; + z-index: 5; + cursor: default; + box-shadow: var(--shadow-lg), var(--shadow-glow); + border-color: var(--accent); +} +.pl-node.active-round { + border-color: var(--accent); + animation: pulse-ring 2.5s var(--ease-in-out) infinite; +} + +.pl-node[data-verdict="advanced"] { border-left: 4px solid var(--verdict-advanced); } +.pl-node[data-verdict="stalled"] { border-left: 4px solid var(--verdict-stalled); } +.pl-node[data-verdict="regressed"] { border-left: 4px solid var(--verdict-regressed); } +.pl-node[data-verdict="complete"] { border-left: 4px solid var(--verdict-complete); } +.pl-node[data-verdict="unknown"] { border-left: 4px solid var(--verdict-unknown); } + + +.node-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + gap: var(--space-2); +} + +.node-round-num { + font-family: var(--font-display); + font-weight: 800; + font-size: 0.95rem; + color: var(--text-0); +} + +.node-meta { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.72rem; + color: var(--text-2); + font-family: var(--font-display); + font-weight: 600; +} + +.node-verdict-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.node-phase-tag { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-3); + padding: 1px 6px; + background: var(--bg-3); + border-radius: var(--radius-xs); +} + +.node-mini-stats { + display: flex; + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-3); + font-size: 0.72rem; + color: var(--text-3); + font-family: var(--font-mono); +} + +/* ─── Flyout Modal (expand from node to center) ─── */ +.flyout-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0); + z-index: 20; + pointer-events: none; + visibility: hidden; + transition: background 300ms var(--ease-out), visibility 0s 300ms; +} +.flyout-overlay.visible { + background: rgba(0, 0, 0, 0.55); + pointer-events: auto; + visibility: visible; + transition: background 300ms var(--ease-out), visibility 0s; +} + +.flyout-panel { + position: absolute; + background: var(--bg-1); + border: 1px solid var(--border-1); + box-shadow: var(--shadow-lg), 0 0 60px rgba(217, 119, 87, 0.08); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.flyout-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-0); + flex-shrink: 0; +} + +.flyout-title { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.flyout-title h3 { + font-size: 1.1rem; + letter-spacing: -0.01em; +} + +.flyout-round-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + border: 2px solid var(--border-2); + font-family: var(--font-display); + font-weight: 800; + font-size: 0.85rem; + color: var(--text-0); + background: var(--bg-2); +} + +.flyout-close { + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-sm); + background: var(--bg-2); + color: var(--text-2); + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--duration-fast); +} +.flyout-close:hover { background: var(--bg-3); color: var(--text-0); } + +.flyout-meta-bar { + display: flex; + flex-wrap: wrap; + gap: var(--space-3) var(--space-5); + padding: var(--space-3) var(--space-5); + background: var(--bg-2); + border-bottom: 1px solid var(--border-0); + font-size: 0.82rem; + color: var(--text-1); + flex-shrink: 0; +} + +.flyout-meta-item strong { + color: var(--text-3); + font-family: var(--font-display); + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.flyout-body { + flex: 1; + overflow-y: auto; + padding: var(--space-5); +} + +.flyout-section { + margin-bottom: var(--space-5); +} +.flyout-section:last-child { margin-bottom: 0; } + +.flyout-section-title { + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-0); +} + +/* ─── Detail Page ─── */ +.detail-layout { + display: grid; + grid-template-columns: 1fr 340px; + grid-template-rows: 1fr auto; + height: calc(100vh - 52px); +} + +.graph-area { + overflow: auto; + background: var(--bg-0); + position: relative; +} + +/* Right sidebar — session-level analysis */ +.session-sidebar { + overflow-y: auto; + padding: var(--space-5); + background: var(--bg-1); + border-left: 1px solid var(--border-0); +} + +.sidebar-section { + margin-bottom: var(--space-5); + padding-bottom: var(--space-5); + border-bottom: 1px solid var(--border-0); +} +.sidebar-section:last-child { border-bottom: none; padding-bottom: 0; } + +.sidebar-title { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); + margin-bottom: var(--space-3); +} + +.sidebar-stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); +} + +.sidebar-stat { + background: var(--bg-2); + border-radius: var(--radius-sm); + padding: var(--space-3); + text-align: center; +} + +.sidebar-stat-num { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 800; + color: var(--accent); + line-height: 1; +} + +.sidebar-stat-label { + font-size: 0.68rem; + color: var(--text-3); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--font-display); + font-weight: 600; +} + +.sidebar-meta { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.sidebar-meta-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; +} + +.sidebar-meta-key { + color: var(--text-3); + font-size: 0.75rem; + font-family: var(--font-display); + font-weight: 600; +} + +.sidebar-meta-val { + color: var(--text-0); + font-weight: 500; + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.sidebar-verdict-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebar-verdict-row { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.8rem; +} + +.sidebar-verdict-bar { + flex: 1; + height: 6px; + background: var(--bg-3); + border-radius: 3px; + overflow: hidden; +} + +.sidebar-verdict-fill { + height: 100%; + border-radius: 3px; + transition: width var(--duration-slow) var(--ease-out); +} + +.sidebar-ac-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebar-ac-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.8rem; + padding: 3px 0; +} + +.sidebar-ac-icon { + font-size: 0.75rem; + flex-shrink: 0; +} + +.sidebar-ac-text { + color: var(--text-1); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta-item-label { + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + margin-bottom: 2px; +} +.meta-item-value { + font-weight: 500; + color: var(--text-0); + font-size: 0.9rem; +} + +/* Goal Tracker Bar */ +.goal-bar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + background: var(--bg-1); + border-top: 1px solid var(--border-0); + overflow-x: auto; +} + +.ac-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + border: 1px solid var(--border-1); + background: var(--bg-2); + color: var(--text-2); + transition: all var(--duration-fast); +} +.ac-pill.done { background: rgba(110, 231, 160, 0.08); color: var(--verdict-advanced); border-color: var(--verdict-advanced); } +.ac-pill.wip { background: rgba(96, 165, 250, 0.08); color: var(--verdict-active); border-color: var(--verdict-active); } + +/* ─── Analytics ─── */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-8); +} + +.stat-card { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5); + text-align: center; + transition: all var(--duration-base) var(--ease-out); +} +.stat-card:hover { border-color: var(--border-2); box-shadow: var(--shadow-sm); } + +.stat-number { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 800; + color: var(--accent); + line-height: 1; + letter-spacing: -0.03em; +} + +.stat-label { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + margin-top: var(--space-2); +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: var(--space-5); + margin-bottom: var(--space-8); +} + +.chart-panel { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5); +} +.chart-panel h4 { + font-size: 0.78rem; + color: var(--text-2); + margin-bottom: var(--space-4); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.chart-wrap { position: relative; height: 220px; } + +/* Verdict Timeline */ +.tl-container { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) 0; +} + +.tl-row { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.tl-label { + width: 110px; + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-2); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tl-label:hover { color: var(--accent); } + +.tl-dots { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.tl-dot { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; + transition: transform var(--duration-fast); + cursor: default; +} +.tl-dot:hover { transform: scale(1.4); } + +.tl-legend { + display: flex; + gap: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--border-0); + margin-top: var(--space-3); + font-size: 0.72rem; + color: var(--text-3); +} + +.tl-legend span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tl-legend .tl-dot { + width: 8px; + height: 8px; +} + +/* Comparison Table */ +.cmp-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.85rem; +} +.cmp-table th { + text-align: left; + padding: 10px 14px; + background: var(--bg-2); + color: var(--text-2); + font-family: var(--font-display); + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border-1); + cursor: pointer; + user-select: none; + transition: color var(--duration-fast); +} +.cmp-table th:hover { color: var(--accent); } +.cmp-table th:first-child { border-radius: var(--radius-sm) 0 0 0; } +.cmp-table th:last-child { border-radius: 0 var(--radius-sm) 0 0; } + +.cmp-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border-0); + color: var(--text-1); +} +.cmp-table tr:hover td { background: var(--bg-glow); } + +/* ─── Empty State ─── */ +.empty { + text-align: center; + padding: var(--space-16) var(--space-6); + color: var(--text-3); +} +.empty-icon { + font-size: 3rem; + margin-bottom: var(--space-4); + opacity: 0.3; +} +.empty-msg { font-size: 1.05rem; color: var(--text-2); } +.empty-hint { font-size: 0.85rem; margin-top: var(--space-2); } + +/* ─── GitHub Section ─── */ +.gh-section { + margin-top: var(--space-5); + padding: var(--space-5); + background: var(--bg-2); + border-radius: var(--radius-md); + border: 1px solid var(--border-1); +} + +.warning-banner { + padding: var(--space-4); + background: rgba(251, 191, 36, 0.06); + border: 1px solid rgba(251, 191, 36, 0.2); + border-radius: var(--radius-sm); + margin-bottom: var(--space-4); + font-size: 0.85rem; + color: var(--verdict-stalled); +} + +/* ─── Responsive ─── */ +@media (max-width: 900px) { + .detail-layout { grid-template-columns: 1fr; grid-template-rows: auto auto auto; } + .session-sidebar { border-left: none; border-top: 1px solid var(--border-0); } + .pipeline-grid { --cols: 2 !important; } + .cards-grid { grid-template-columns: 1fr; } + .charts-grid { grid-template-columns: 1fr; } +} diff --git a/viz/static/css/theme.css b/viz/static/css/theme.css new file mode 100644 index 00000000..901af5d4 --- /dev/null +++ b/viz/static/css/theme.css @@ -0,0 +1,420 @@ +/* + * Humanize Viz — Design System + * Aesthetic: "Mission Control" — refined dark dashboard with warm orange accents + * Font: Archivo (display), DM Sans (body), JetBrains Mono (code) + */ + +/* ─── Design Tokens ─── */ +:root { + --font-display: 'Archivo', 'Noto Sans SC', sans-serif; + --font-body: 'DM Sans', 'Noto Sans SC', sans-serif; + --font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); + --duration-fast: 120ms; + --duration-base: 250ms; + --duration-slow: 500ms; + --duration-expand: 400ms; + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; +} + +/* ─── Dark Theme ─── */ +[data-theme="dark"] { + --bg-0: #0f0f12; + --bg-1: #17171c; + --bg-2: #1e1e24; + --bg-3: #26262e; + --bg-4: #2f2f38; + --bg-glow: rgba(217, 119, 87, 0.04); + + --text-0: #f0ede8; + --text-1: #c4c0b8; + --text-2: #8a877f; + --text-3: #5c5a54; + + --accent: #d97757; + --accent-hover: #e8906e; + --accent-dim: rgba(217, 119, 87, 0.12); + --accent-glow: rgba(217, 119, 87, 0.25); + + --border-0: rgba(255, 255, 255, 0.04); + --border-1: rgba(255, 255, 255, 0.08); + --border-2: rgba(255, 255, 255, 0.14); + + --verdict-advanced: #6ee7a0; + --verdict-stalled: #fbbf24; + --verdict-regressed: #f87171; + --verdict-active: #60a5fa; + --verdict-unknown: #6b7280; + --verdict-complete: #a78bfa; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: 0 4px 16px rgba(0,0,0,0.4); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.5); + --shadow-glow: 0 0 30px rgba(217, 119, 87, 0.1); + + --grain-opacity: 0.03; + color-scheme: dark; +} + +/* ─── Light Theme ─── */ +[data-theme="light"] { + --bg-0: #f8f6f2; + --bg-1: #ffffff; + --bg-2: #f0ede8; + --bg-3: #e6e3dc; + --bg-4: #d9d6cf; + --bg-glow: rgba(217, 119, 87, 0.03); + + --text-0: #1a1815; + --text-1: #3d3a35; + --text-2: #7a776f; + --text-3: #a8a59d; + + --accent: #c4623f; + --accent-hover: #b05535; + --accent-dim: rgba(196, 98, 63, 0.08); + --accent-glow: rgba(196, 98, 63, 0.15); + + --border-0: rgba(0, 0, 0, 0.04); + --border-1: rgba(0, 0, 0, 0.08); + --border-2: rgba(0, 0, 0, 0.14); + + --verdict-advanced: #16a34a; + --verdict-stalled: #ca8a04; + --verdict-regressed: #dc2626; + --verdict-active: #2563eb; + --verdict-unknown: #6b7280; + --verdict-complete: #7c3aed; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 16px rgba(0,0,0,0.08); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.12); + --shadow-glow: 0 0 30px rgba(196, 98, 63, 0.06); + + --grain-opacity: 0.015; + color-scheme: light; +} + +/* ─── Reset & Base ─── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-size: 15px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-body); + color: var(--text-0); + background: var(--bg-0); + line-height: 1.6; + min-height: 100vh; + transition: background var(--duration-base) var(--ease-out), + color var(--duration-base) var(--ease-out); +} + +/* ─── Grain Overlay ─── */ +.grain-overlay { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + opacity: var(--grain-opacity); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px; +} + +/* ─── Typography ─── */ +h1, h2, h3, h4, h5 { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.15rem; } +h4 { font-size: 1rem; } + +code, pre, .mono { + font-family: var(--font-mono); + font-size: 0.87rem; +} + +pre { + background: var(--bg-2); + border: 1px solid var(--border-1); + border-radius: var(--radius-sm); + padding: var(--space-4); + overflow-x: auto; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color var(--duration-fast); +} +a:hover { color: var(--accent-hover); } + +::selection { + background: var(--accent-dim); + color: var(--text-0); +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-3); } + +/* ─── Badges ─── */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px 10px; + border-radius: var(--radius-full); + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.badge-active { background: rgba(96, 165, 250, 0.12); color: var(--verdict-active); } +.badge-complete { background: rgba(167, 139, 250, 0.12); color: var(--verdict-complete); } +.badge-cancel { background: rgba(248, 113, 113, 0.12); color: var(--verdict-regressed); } +.badge-stop, .badge-maxiter { background: rgba(251, 191, 36, 0.12); color: var(--verdict-stalled); } +.badge-unknown, .badge-analyzing, .badge-finalizing { background: rgba(107, 114, 128, 0.12); color: var(--verdict-unknown); } + +/* ─── Verdict Colors ─── */ +.verdict-advanced { color: var(--verdict-advanced); } +.verdict-stalled { color: var(--verdict-stalled); } +.verdict-regressed { color: var(--verdict-regressed); } +.verdict-unknown { color: var(--verdict-unknown); } +.verdict-complete { color: var(--verdict-complete); } + +/* ─── Buttons ─── */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 8px 18px; + border: 1px solid var(--border-2); + border-radius: var(--radius-sm); + background: var(--bg-2); + color: var(--text-0); + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-out); + letter-spacing: 0.02em; +} +.btn:hover { background: var(--bg-3); border-color: var(--accent); transform: translateY(-1px); } +.btn:active { transform: translateY(0); } + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: transparent; +} +.btn-primary:hover { background: var(--accent-hover); border-color: transparent; box-shadow: var(--shadow-glow); } + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-2); +} +.btn-ghost:hover { color: var(--text-0); background: var(--bg-2); border-color: transparent; } + +.btn-danger { color: var(--verdict-regressed); } +.btn-danger:hover { background: rgba(248,113,113,0.08); border-color: var(--verdict-regressed); } + +/* ─── Tabs ─── */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-1); + margin-bottom: var(--space-6); +} + +.tab { + padding: 10px 20px; + cursor: pointer; + color: var(--text-2); + border-bottom: 2px solid transparent; + font-family: var(--font-display); + font-size: 0.85rem; + font-weight: 600; + transition: all var(--duration-fast); + letter-spacing: 0.01em; +} +.tab:hover { color: var(--text-0); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ─── Modal ─── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + visibility: hidden; + transition: background var(--duration-base) var(--ease-out), + visibility 0s linear var(--duration-base); +} +.modal-overlay.visible { + background: rgba(0, 0, 0, 0.65); + pointer-events: auto; + visibility: visible; + transition: background var(--duration-base) var(--ease-out), visibility 0s; +} + +.modal { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 680px; + width: 92%; + max-height: 82vh; + overflow-y: auto; + padding: var(--space-8); + transform: scale(0.92) translateY(12px); + opacity: 0; + transition: transform var(--duration-slow) var(--ease-out), + opacity var(--duration-base) var(--ease-out); +} +.modal-overlay.visible .modal { + transform: scale(1) translateY(0); + opacity: 1; +} + +.modal h3 { + font-size: 1.2rem; + margin-bottom: var(--space-5); +} + +.modal-actions { + display: flex; + gap: var(--space-3); + justify-content: flex-end; + margin-top: var(--space-6); + padding-top: var(--space-5); + border-top: 1px solid var(--border-0); +} + +/* ─── Dropdown ─── */ +.dropdown { position: relative; } + +.dropdown-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--bg-2); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 200px; + z-index: 100; + overflow: hidden; + padding: var(--space-1) 0; +} +.dropdown-menu.open { display: block; } + +.dropdown-item { + display: block; + width: 100%; + padding: 9px 16px; + text-align: left; + border: none; + background: none; + color: var(--text-1); + font-family: var(--font-body); + font-size: 0.87rem; + cursor: pointer; + transition: all var(--duration-fast); +} +.dropdown-item:hover { background: var(--bg-3); color: var(--text-0); } +.dropdown-item.danger { color: var(--verdict-regressed); } +.dropdown-item.danger:hover { background: rgba(248,113,113,0.06); } +.dropdown-divider { border: none; border-top: 1px solid var(--border-0); margin: var(--space-1) 0; } + +/* ─── Markdown ─── */ +.md h1 { font-size: 1.3rem; margin: var(--space-5) 0 var(--space-3); } +.md h2 { font-size: 1.1rem; margin: var(--space-4) 0 var(--space-2); color: var(--accent); } +.md h3 { font-size: 0.95rem; margin: var(--space-3) 0 var(--space-2); } +.md p { margin: var(--space-2) 0; color: var(--text-1); } +.md ul, .md ol { padding-left: 20px; margin: var(--space-2) 0; } +.md li { margin: 2px 0; color: var(--text-1); } +.md strong { color: var(--text-0); } +.md table { border-collapse: collapse; width: 100%; margin: var(--space-3) 0; font-size: 0.87rem; } +.md th, .md td { border: 1px solid var(--border-1); padding: 6px 12px; text-align: left; } +.md th { background: var(--bg-3); color: var(--text-2); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +.md blockquote { border-left: 3px solid var(--accent); padding-left: 14px; color: var(--text-2); margin: var(--space-3) 0; } + +/* ─── Progress Bar ─── */ +.progress-bar { + width: 100%; + height: 5px; + background: var(--bg-3); + border-radius: var(--radius-full); + overflow: hidden; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--ease-out); +} + +/* ─── Pulse Keyframes ─── */ +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 var(--accent-glow); } + 70% { box-shadow: 0 0 0 10px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slide-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ─── Print ─── */ +@media print { + .topbar, .grain-overlay, .dropdown { display: none !important; } + body { background: #fff; color: #000; } + .modal-overlay { display: none !important; } +} diff --git a/viz/static/index.html b/viz/static/index.html new file mode 100644 index 00000000..05ef7a67 --- /dev/null +++ b/viz/static/index.html @@ -0,0 +1,67 @@ + + + + + + Humanize Viz + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ Home + + + +
+
+ + +
+ + + + + + + + + + + + diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js new file mode 100644 index 00000000..0ecaa5e7 --- /dev/null +++ b/viz/static/js/actions.js @@ -0,0 +1,124 @@ +/* Action handlers — cancel, export, GitHub issue, plan viewer */ + +function toggleOpsMenu() { + const menu = document.getElementById('ops-dropdown') + if (menu) menu.classList.toggle('open') +} + +document.addEventListener('click', (e) => { + if (!e.target.closest('.dropdown')) + document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open')) +}) + +// ─── Cancel ─── +function showCancelModal(sessionId) { + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('cancel.title')}

+

${t('cancel.message')}

+ ` + document.getElementById('modal-overlay').classList.add('visible') +} + +async function confirmCancel(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/cancel`, { method: 'POST' }) + closeModal() + if (res.ok) window.renderCurrentRoute() + else { const e = await res.json(); alert(e.error || t('cancel.failed')) } +} + +function closeModal() { + document.getElementById('modal-overlay').classList.remove('visible') +} + +// ─── Export ─── +async function exportMarkdown(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/export`, { method: 'POST' }) + if (!res.ok) return + const data = await res.json() + const blob = new Blob([data.content], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = data.filename || `rlcr-report-${sessionId}.md` + a.click() + URL.revokeObjectURL(url) +} + +function exportPdf() { window.print() } + +// ─── GitHub Issue (sanitized) ─── +async function previewGitHubIssue(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) return + const data = await res.json() + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('analysis.preview')}

+
+
${t('analysis.issue_title')}
+ ${esc(data.title)} +
+
+
${t('analysis.issue_body')}
+
+ ${marked.parse(data.body)} +
+
+ ` + document.getElementById('modal-overlay').classList.add('visible') +} + +async function sendGitHubIssue(sessionId) { + closeModal() + const ghResult = document.getElementById('gh-result') + if (ghResult) ghResult.innerHTML = `${t('analysis.sending')}` + const res = await fetch(`/api/sessions/${sessionId}/github-issue`, { method: 'POST' }) + const data = await res.json() + if (res.ok && data.url) { + if (ghResult) ghResult.innerHTML = `✓ ${t('analysis.sent')} — ${data.url}` + } else if (data.manual) { + window._issuePayload = `Title: ${data.title || ''}\n\n${data.body || ''}` + if (ghResult) ghResult.innerHTML = `${esc(data.error)}
` + } else { + if (ghResult) ghResult.innerHTML = `${esc(data.error || t('analysis.failed'))}` + } +} + +async function copyIssueContent(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) return + const data = await res.json() + copyToClipboard(`Title: ${data.title}\n\n${data.body}`) +} + +function copyToClipboard(text) { + navigator.clipboard.writeText(text).catch(() => { + const ta = document.createElement('textarea') + ta.value = text + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + }) +} + +// ─── Plan Viewer ─── +async function showPlanViewer(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/plan`) + if (!res.ok) return + const data = await res.json() + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('ops.view_plan')}

+
${marked.parse(data.content)}
+ ` + document.getElementById('modal-overlay').classList.add('visible') +} diff --git a/viz/static/js/app.js b/viz/static/js/app.js new file mode 100644 index 00000000..55956bca --- /dev/null +++ b/viz/static/js/app.js @@ -0,0 +1,489 @@ +/* Main SPA — router, WebSocket, page rendering */ + +let ws = null, wsRetryDelay = 1000 +const WS_MAX_RETRY = 30000 +let _sortCol = 'session_id', _sortAsc = true + +// ─── WebSocket ─── +function connectWebSocket() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + ws = new WebSocket(`${proto}//${location.host}/ws`) + ws.onopen = () => { wsRetryDelay = 1000 } + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + const route = parseRoute() + if (route.page === 'home') renderHome() + else if (route.page === 'session' && route.id === msg.session_id) renderSession(route.id) + } catch (_) {} + } + ws.onclose = () => { + setTimeout(() => { + wsRetryDelay = Math.min(wsRetryDelay * 2, WS_MAX_RETRY) + connectWebSocket() + }, wsRetryDelay) + } +} + +// ─── Router ─── +function parseRoute() { + const h = location.hash || '#/' + if (h === '#/' || h === '#') return { page: 'home' } + let m = h.match(/^#\/session\/([^/]+)\/analysis$/) + if (m) return { page: 'analysis', id: m[1] } + m = h.match(/^#\/session\/([^/]+)$/) + if (m) return { page: 'session', id: m[1] } + if (h === '#/analytics') return { page: 'analytics' } + return { page: 'home' } +} + +function navigate(hash) { location.hash = hash } + +window.renderCurrentRoute = function() { + const route = parseRoute() + const main = document.getElementById('main-content') + main.innerHTML = '' + updateTopbar(route) + switch (route.page) { + case 'home': renderHome(); break + case 'session': renderSession(route.id); break + case 'analysis': renderAnalysis(route.id); break + case 'analytics': renderAnalytics(); break + default: renderHome() + } +} + +window.addEventListener('hashchange', window.renderCurrentRoute) + +// ─── Topbar ─── +function updateTopbar(route) { + const left = document.getElementById('topbar-left') + const titleEl = document.getElementById('topbar-title') + const themeBtn = document.getElementById('theme-btn') + const analyticsLink = document.getElementById('analytics-link') + const opsContainer = document.getElementById('ops-dropdown-container') + + // Left area: always show logo (clickable to home), plus back button on sub-pages + if (route.page === 'home') { + left.innerHTML = ` + ` + titleEl.textContent = '' + } else { + left.innerHTML = ` + ${t('nav.back')} + ` + titleEl.textContent = route.id || '' + } + + // Right area + if (analyticsLink) analyticsLink.textContent = t('nav.analytics') + if (themeBtn) themeBtn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾' + + // Ops dropdown — only on session/analysis pages + if (opsContainer) { + opsContainer.style.display = (route.page === 'session' || route.page === 'analysis') ? '' : 'none' + } + + // Populate ops menu labels + const labels = { 'ops-plan': 'ops.view_plan', 'ops-analysis': 'ops.analysis', 'ops-export-md': 'ops.export_md', 'ops-export-pdf': 'ops.export_pdf', 'ops-cancel': 'ops.cancel' } + for (const [id, key] of Object.entries(labels)) { + const el = document.getElementById(id) + if (el) el.textContent = t(key) + } +} + +// ─── Theme ─── +function initTheme() { + const saved = localStorage.getItem('humanize-viz-theme') + const theme = (saved === 'dark' || saved === 'light') ? saved : 'dark' + document.documentElement.setAttribute('data-theme', theme) + if (saved !== theme) localStorage.setItem('humanize-viz-theme', theme) +} + +function toggleTheme() { + const cur = document.documentElement.getAttribute('data-theme') + const next = cur === 'dark' ? 'light' : 'dark' + document.documentElement.setAttribute('data-theme', next) + localStorage.setItem('humanize-viz-theme', next) + // Update theme button icon immediately + const btn = document.getElementById('theme-btn') + if (btn) btn.textContent = next === 'dark' ? '☀' : '☾' + // Re-render only the main content (topbar stays) + const route = parseRoute() + const main = document.getElementById('main-content') + main.innerHTML = '' + switch (route.page) { + case 'home': renderHome(); break + case 'session': renderSession(route.id); break + case 'analysis': renderAnalysis(route.id); break + case 'analytics': renderAnalytics(); break + default: renderHome() + } +} + +// ─── API ─── +async function api(url) { + const r = await fetch(url) + return r.ok ? r.json() : null +} + +function fmtDuration(m) { + if (m == null) return '—' + if (m < 60) return `${m} ${t('unit.min')}` + return `${Math.floor(m/60)}h ${Math.round(m%60)}m` +} + +function _esc(str) { + const d = document.createElement('div') + d.textContent = str || '' + return d.innerHTML +} + +// ─── Home ─── +async function renderHome() { + const main = document.getElementById('main-content') + const sessions = await api('/api/sessions') + + if (!sessions || sessions.length === 0) { + main.innerHTML = `
${t('home.empty')}
${t('home.empty.hint')}
` + return + } + + const active = sessions.filter(s => ['active','analyzing','finalizing'].includes(s.status)) + const finished = sessions.filter(s => !['active','analyzing','finalizing'].includes(s.status)) + + let html = '' + if (active.length) { + html += `
${active.map(sessionCard).join('')}
` + } + if (finished.length) { + html += `
${finished.map(sessionCard).join('')}
` + } + + main.innerHTML = `
${html}
` +} + +function sessionCard(s) { + const plan = s.plan_file ? s.plan_file.split('/').pop() : '—' + const started = s.started_at ? new Date(s.started_at).toLocaleString() : '—' + const acPct = s.ac_total > 0 ? Math.round(s.ac_done / s.ac_total * 100) : 0 + + return ` +
+
+ ${t('status.' + s.status)} + ${t('card.round')} ${s.current_round}/${s.max_iterations} +
+
+
${t('card.plan')}
${esc(plan)}
+
${t('card.branch')}
${esc(s.start_branch || '—')}
+
${t('card.verdict')}
${s.last_verdict}
+
${t('card.ac')}
${s.ac_done}/${s.ac_total}
+
${t('card.duration')}
${fmtDuration(s.duration_minutes)}
+
${t('card.started')}
${started}
+
+
+
+ ${t('detail.tasks')}: ${s.tasks_done}/${s.tasks_total} +
+
` +} + +// ─── Session Detail ─── +async function renderSession(sessionId) { + const main = document.getElementById('main-content') + const session = await api(`/api/sessions/${sessionId}`) + if (!session) { + main.innerHTML = `
${t('detail.not_found')}
` + return + } + + main.innerHTML = ` +
+
+
+
+
+
+
` + + renderPipeline(document.getElementById('pipeline-root'), session) + renderSessionSidebar(session) + renderGoalBar(session) + window._currentSession = session + + const cancelBtn = document.getElementById('ops-cancel') + if (cancelBtn) cancelBtn.style.display = session.status === 'active' ? '' : 'none' +} + +function renderSessionSidebar(s) { + const sidebar = document.getElementById('session-sidebar') + if (!sidebar) return + + const acTotal = s.ac_total || 0 + const acDone = s.ac_done || 0 + const acPct = acTotal > 0 ? Math.round(acDone / acTotal * 100) : 0 + + const vCounts = { advanced: 0, stalled: 0, regressed: 0 } + let reviewedRounds = 0 + for (const r of (s.rounds || [])) { + if (r.review_result && selectLang(r.review_result)) { + const v = r.verdict + if (v in vCounts) vCounts[v]++ + reviewedRounds++ + } + } + + const verdictBars = Object.entries(vCounts).map(([v, count]) => { + const pct = reviewedRounds > 0 ? Math.round(count / reviewedRounds * 100) : 0 + return `` + }).join('') + + const acs = s.goal_tracker?.acceptance_criteria || [] + const acListHtml = acs.map(ac => { + const icon = ac.status === 'completed' ? '✓' : ac.status === 'in_progress' ? '◉' : '○' + const color = ac.status === 'completed' ? 'var(--verdict-advanced)' : ac.status === 'in_progress' ? 'var(--verdict-active)' : 'var(--text-3)' + return `` + }).join('') + + const plan = s.plan_file ? s.plan_file.split('/').pop() : '—' + const started = s.started_at ? new Date(s.started_at).toLocaleString() : '—' + + sidebar.innerHTML = ` + + + + ${acs.length > 0 ? ` + ` : ''}` +} + +function renderGoalBar(session) { + const bar = document.getElementById('goal-bar') + if (!bar || !session.goal_tracker) return + const acs = session.goal_tracker.acceptance_criteria || [] + bar.innerHTML = acs.map(ac => { + const cls = ac.status === 'completed' ? 'done' : ac.status === 'in_progress' ? 'wip' : '' + const icon = ac.status === 'completed' ? '✓' : ac.status === 'in_progress' ? '◉' : '○' + return `${icon} ${ac.id}` + }).join('') +} + +// ─── Analysis ─── +async function renderAnalysis(sessionId) { + const main = document.getElementById('main-content') + const session = await api(`/api/sessions/${sessionId}`) + if (!session) { + main.innerHTML = `
${t('detail.not_found')}
` + return + } + + const report = selectLang(session.methodology_report) + const hasReport = !!report + + let sanitizedHtml = `
${t('analysis.no_report')}
` + if (hasReport) { + const sanitized = await api(`/api/sessions/${sessionId}/sanitized-issue`) + if (sanitized) { + const w = sanitized.warnings || {} + const hasW = sanitized.requires_review || Object.keys(w).length > 0 + const warnBanner = hasW ? `
${t('analysis.review_warning')}
${Object.entries(w).map(([c,n]) => `• ${esc(c)}: ${n}`).join(' ')}
` : '' + const btns = hasW ? '' : `
` + sanitizedHtml = `${warnBanner}
${marked.parse(sanitized.body)}
${t('analysis.gh_repo')}: humania-org/humanize
${btns}
` + } + } + + main.innerHTML = ` +
+
+
${t('analysis.report_tab')}
+
${t('analysis.summary_tab')}
+
+
+ ${hasReport ? `
${marked.parse(report)}
` : `
${t('analysis.no_report')}
`} +
+ +
` + + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')) + document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none') + tab.classList.add('active') + document.getElementById('tab-' + tab.dataset.tab).style.display = 'block' + }) + }) + window._currentSession = session +} + +// ─── Analytics ─── +async function renderAnalytics() { + const main = document.getElementById('main-content') + const data = await api('/api/analytics') + if (!data) { + main.innerHTML = `
${t('analytics.no_data')}
` + return + } + + const o = data.overview + + // Build per-session round verdict timeline + let timelineHtml = '' + if (data.session_stats && data.session_stats.length > 0) { + // Fetch full session data for verdict timeline + const sessions = await Promise.all( + data.session_stats.map(s => api(`/api/sessions/${s.session_id}`)) + ) + timelineHtml = sessions.filter(Boolean).map(s => { + const dots = (s.rounds || []).map(r => { + const v = r.verdict || 'unknown' + const title = `R${r.number}: ${v}` + return `` + }).join('') + return `
+ ${s.id.slice(5, 16).replace('_', ' ')} +
${dots}
+ ${t('status.' + s.status)} +
` + }).join('') + } + + main.innerHTML = ` +
+

${t('analytics.title')}

+
+
${o.total_sessions}
${t('analytics.total')}
+
${o.average_rounds}
${t('analytics.avg_rounds')}
+
${o.completion_rate}%
${t('analytics.completion')}
+
${o.total_bitlessons}
${t('analytics.bitlessons')}
+
+ + ${timelineHtml ? ` + +
+
${timelineHtml}
+
+ advanced + stalled + regressed + complete + unknown +
+
` : ''} + +
+

${t('analytics.rounds_trend')}

+

${t('analytics.duration')}

+

${t('analytics.verdicts')}

+

${t('analytics.p_issues')}

+

${t('analytics.first_complete')}

+

${t('analytics.bl_growth')}

+
+

${t('analytics.comparison')}

+
+
` + + if (typeof buildCharts === 'function') buildCharts(data) + buildCmpTable(data.session_stats) +} + +function buildCmpTable(stats) { + const root = document.getElementById('cmp-root') + if (!root || !stats || !stats.length) return + + const sorted = [...stats].sort((a, b) => { + let va, vb + switch (_sortCol) { + case 'rounds': va = a.rounds; vb = b.rounds; break + case 'duration': va = a.avg_duration_minutes || 0; vb = b.avg_duration_minutes || 0; break + case 'verdict': va = (a.verdict_breakdown||{}).advanced||0; vb = (b.verdict_breakdown||{}).advanced||0; break + case 'rework': va = a.rework_count; vb = b.rework_count; break + case 'ac': va = a.ac_completion_rate; vb = b.ac_completion_rate; break + default: va = a.session_id; vb = b.session_id + } + return _sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (va > vb ? -1 : va < vb ? 1 : 0) + }) + + const arr = c => _sortCol === c ? (_sortAsc ? ' ▲' : ' ▼') : '' + const cols = [ + ['session_id', 'Session'], + [null, 'Status'], + ['rounds', 'Rounds'], + ['duration', 'Duration'], + ['verdict', 'Verdict (A/S/R)'], + ['rework', 'Rework'], + ['ac', 'AC %'], + ] + + let html = `${cols.map(([k, label]) => + k ? `` : `` + ).join('')}` + + for (const s of sorted) { + const vb = s.verdict_breakdown || {} + html += ` + + + + + + + + ` + } + html += '
${label}${arr(k)}${label}
${s.session_id}${t('status.' + s.status)}${s.rounds}${s.avg_duration_minutes != null ? s.avg_duration_minutes + ' min' : '—'}${vb.advanced||0}/${vb.stalled||0}/${vb.regressed||0}${s.rework_count}${s.ac_completion_rate}%
' + root.innerHTML = html + window._cmpStats = stats +} + +function sortCmp(col) { + if (_sortCol === col) _sortAsc = !_sortAsc + else { _sortCol = col; _sortAsc = true } + if (window._cmpStats) buildCmpTable(window._cmpStats) +} + +// ─── Init ─── +document.addEventListener('DOMContentLoaded', () => { + initTheme() + connectWebSocket() + window.renderCurrentRoute() +}) diff --git a/viz/static/js/charts.js b/viz/static/js/charts.js new file mode 100644 index 00000000..a27f8453 --- /dev/null +++ b/viz/static/js/charts.js @@ -0,0 +1,155 @@ +/* Chart.js analytics — theme-aware, handles sparse data gracefully */ + +const _charts = {} + +function _colors() { + const s = getComputedStyle(document.documentElement) + const g = k => s.getPropertyValue(k).trim() + return { + accent: g('--accent') || '#d97757', + success: g('--verdict-advanced') || '#6ee7a0', + warning: g('--verdict-stalled') || '#fbbf24', + danger: g('--verdict-regressed') || '#f87171', + info: g('--verdict-active') || '#60a5fa', + purple: g('--verdict-complete') || '#a78bfa', + muted: g('--verdict-unknown') || '#6b7280', + text: g('--text-2') || '#8a877f', + gridLine: g('--border-1') || 'rgba(255,255,255,0.06)', + bg2: g('--bg-2') || '#1e1e24', + } +} + +function _baseOpts(c) { + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 600 }, + plugins: { + legend: { display: false }, + tooltip: { backgroundColor: c.bg2, titleColor: c.text, bodyColor: c.text, borderColor: c.accent, borderWidth: 1, cornerRadius: 8, padding: 10 }, + }, + scales: { + x: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.gridLine }, border: { color: c.gridLine } }, + y: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.gridLine }, border: { color: c.gridLine }, beginAtZero: true }, + } + } +} + +function _noScaleOpts(c) { + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 600 }, + plugins: { + legend: { position: 'right', labels: { color: c.text, font: { size: 11 }, padding: 12, usePointStyle: true, pointStyleWidth: 10 } }, + tooltip: { backgroundColor: c.bg2, titleColor: c.text, bodyColor: c.text, borderColor: c.accent, borderWidth: 1, cornerRadius: 8, padding: 10 }, + }, + } +} + +function _showEmpty(canvasId, msg) { + const el = document.getElementById(canvasId) + if (!el) return + el.parentElement.innerHTML = `
${msg}
` +} + +function _makeChart(canvasId, config) { + const el = document.getElementById(canvasId) + if (!el) { console.warn('[charts] canvas not found:', canvasId); return null } + try { + return new Chart(el, config) + } catch (e) { + console.error('[charts] failed to create', canvasId, e) + return null + } +} + +function buildCharts(data) { + // Destroy previous charts + Object.values(_charts).forEach(ch => { try { ch.destroy() } catch(e) {} }) + for (const k of Object.keys(_charts)) delete _charts[k] + + const c = _colors() + const stats = data.session_stats || [] + const labels = stats.map(s => s.session_id.slice(5, 16).replace('_', ' ')) + + // 1. Rounds per session + if (stats.length > 0) { + const ch = _makeChart('c-rounds', { + type: stats.length === 1 ? 'bar' : 'line', + data: { labels, datasets: [{ label: 'Rounds', data: stats.map(s => s.rounds), borderColor: c.accent, backgroundColor: stats.length === 1 ? c.accent + 'cc' : c.accent + '18', fill: stats.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.accent, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.rounds = ch + } else { + _showEmpty('c-rounds', 'No session data yet') + } + + // 2. Avg round duration + if (stats.some(s => s.avg_duration_minutes != null)) { + const ch = _makeChart('c-duration', { + type: 'bar', + data: { labels, datasets: [{ label: 'Avg Duration (min)', data: stats.map(s => s.avg_duration_minutes), backgroundColor: c.info + 'aa', borderColor: c.info, borderWidth: 1, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.dur = ch + } else { + _showEmpty('c-duration', 'No duration data available') + } + + // 3. Verdict distribution (doughnut) + const vd = data.verdict_distribution || {} + const vdEntries = Object.entries(vd).filter(([_, v]) => v > 0) + if (vdEntries.length > 0) { + const colorMap = { advanced: c.success, stalled: c.warning, regressed: c.danger, complete: c.purple, unknown: c.muted } + const ch = _makeChart('c-verdicts', { + type: 'doughnut', + data: { labels: vdEntries.map(([k]) => k), datasets: [{ data: vdEntries.map(([_, v]) => v), backgroundColor: vdEntries.map(([k]) => colorMap[k] || c.muted), borderWidth: 2, borderColor: c.bg2 }] }, + options: _noScaleOpts(c), + }) + if (ch) _charts.v = ch + } else { + _showEmpty('c-verdicts', 'No reviewed rounds yet') + } + + // 4. P-issues distribution + const pd = data.p_distribution || {} + const pk = Object.keys(pd).sort() + if (pk.length > 0) { + const palette = [c.danger, c.warning, c.accent, c.info, c.success, c.purple, c.muted] + const ch = _makeChart('c-pissues', { + type: 'bar', + data: { labels: pk, datasets: [{ label: 'Issues', data: pk.map(k => pd[k]), backgroundColor: pk.map((_, i) => palette[i % palette.length] + 'bb'), borderColor: pk.map((_, i) => palette[i % palette.length]), borderWidth: 1, borderRadius: 6 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.p = ch + } else { + _showEmpty('c-pissues', 'No P0-P9 issues recorded') + } + + // 5. First COMPLETE round + const fcData = stats.filter(s => s.first_complete_round != null && s.first_complete_round > 0) + if (fcData.length > 0) { + const ch = _makeChart('c-fc', { + type: fcData.length === 1 ? 'bar' : 'line', + data: { labels: fcData.map(s => s.session_id.slice(5, 16).replace('_', ' ')), datasets: [{ label: 'First COMPLETE at Round', data: fcData.map(s => s.first_complete_round), borderColor: c.success, backgroundColor: fcData.length === 1 ? c.success + 'cc' : c.success + '18', fill: fcData.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.success, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.fc = ch + } else { + _showEmpty('c-fc', 'No sessions reached COMPLETE yet') + } + + // 6. BitLesson growth + const bl = data.bitlesson_growth || [] + if (bl.length > 0 && bl.some(b => b.cumulative > 0)) { + const ch = _makeChart('c-bl', { + type: bl.length === 1 ? 'bar' : 'line', + data: { labels: bl.map(b => b.session_id.slice(5, 16).replace('_', ' ')), datasets: [{ label: 'Cumulative BitLessons', data: bl.map(b => b.cumulative), borderColor: c.accent, backgroundColor: bl.length === 1 ? c.accent + 'cc' : c.accent + '25', fill: bl.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.accent, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.bl = ch + } else { + _showEmpty('c-bl', 'No BitLesson entries yet') + } +} diff --git a/viz/static/js/i18n.js b/viz/static/js/i18n.js new file mode 100644 index 00000000..9199d356 --- /dev/null +++ b/viz/static/js/i18n.js @@ -0,0 +1,93 @@ +/* UI labels — English only */ + +const _LABELS = { + 'app.title': 'Humanize Viz', + 'nav.analytics': 'Analytics', + 'nav.back': '← Back', + 'home.active': 'Active', + 'home.completed': 'Completed', + 'home.empty': 'No RLCR sessions found', + 'home.empty.hint': 'Start an RLCR loop in your project and sessions will appear here.', + 'card.round': 'Round', + 'card.plan': 'Plan', + 'card.branch': 'Branch', + 'card.verdict': 'Verdict', + 'card.ac': 'AC', + 'card.started': 'Started', + 'card.duration': 'Duration', + 'detail.summary': 'Summary', + 'detail.review': 'Codex Review', + 'detail.phase': 'Phase', + 'detail.tasks': 'Tasks', + 'detail.bitlesson': 'BitLesson', + 'detail.no_summary': 'Summary not yet available', + 'detail.no_review': 'Review not yet available', + 'detail.not_found': 'Session not found', + 'detail.click_node': 'Click a node to expand round details', + 'ops.view_plan': 'View Plan', + 'ops.analysis': 'Methodology Analysis', + 'ops.export_md': 'Export Markdown', + 'ops.export_pdf': 'Export PDF', + 'ops.cancel': 'Cancel Loop', + 'cancel.title': 'Confirm Cancel', + 'cancel.message': 'Cancel the current RLCR loop? This cannot be undone.', + 'cancel.confirm': 'Confirm', + 'cancel.dismiss': 'Close', + 'cancel.failed': 'Cancel failed', + 'analysis.report_tab': 'Methodology Report', + 'analysis.summary_tab': 'Sanitized Summary', + 'analysis.no_report': 'Analysis report not yet available', + 'analysis.gh_repo': 'Target repo', + 'analysis.preview': 'Preview Issue', + 'analysis.send': 'Send to GitHub', + 'analysis.copy': 'Copy Content', + 'analysis.sent': 'Sent', + 'analysis.sending': 'Sending...', + 'analysis.failed': 'Failed', + 'analysis.issue_title': 'Title', + 'analysis.issue_body': 'Body', + 'analysis.review_warning': '⚠ Sanitization check found issues. Review the methodology report manually and remove project-specific content before sending.', + 'analytics.title': 'Cross-Session Analytics', + 'analytics.total': 'Total Sessions', + 'analytics.avg_rounds': 'Avg Rounds', + 'analytics.completion': 'Completion Rate', + 'analytics.bitlessons': 'Total BitLessons', + 'analytics.rounds_trend': 'Rounds per Session', + 'analytics.duration': 'Avg Round Duration (min)', + 'analytics.verdicts': 'Verdict Distribution', + 'analytics.p_issues': 'P0-P9 Issues', + 'analytics.first_complete': 'First COMPLETE Round', + 'analytics.bl_growth': 'BitLesson Growth', + 'analytics.comparison': 'Session Comparison', + 'analytics.no_data': 'No analytics data', + 'analytics.col_session': 'Session', + 'analytics.col_status': 'Status', + 'analytics.rework': 'Rework', + 'status.active': 'Active', + 'status.complete': 'Complete', + 'status.cancel': 'Cancelled', + 'status.stop': 'Stopped', + 'status.maxiter': 'Max Iter', + 'status.unknown': 'Unknown', + 'status.analyzing': 'Analyzing', + 'status.finalizing': 'Finalizing', + 'phase.implementation': 'Impl', + 'phase.code_review': 'Review', + 'phase.finalize': 'Final', + 'node.setup': 'Setup', + 'unit.min': 'min', +} + +function t(key) { + return _LABELS[key] || key +} + +// Content language selection from {zh, en} objects — prefer English +function selectLang(content) { + if (!content) return null + if (typeof content === 'string') return content + if (typeof content === 'object') { + return content['en'] || content['zh'] || null + } + return null +} diff --git a/viz/static/js/pipeline.js b/viz/static/js/pipeline.js new file mode 100644 index 00000000..19c06afe --- /dev/null +++ b/viz/static/js/pipeline.js @@ -0,0 +1,302 @@ +/* Pipeline — snake-path node layout with SVG connectors + zoom/pan + flyout detail */ + +const PL = { + COLS: 4, + NODE_W: 230, + NODE_H: 72, + GAP_X: 52, + GAP_Y: 48, + TURN_H: 56, + PADDING: 40, +} + +let _scale = 1, _tx = 0, _ty = 0 +let _dragging = false, _dragStartX = 0, _dragStartY = 0, _dragTx = 0, _dragTy = 0 + +function renderPipeline(container, session) { + if (!container || !session) return + const rounds = session.rounds || [] + if (rounds.length === 0) { + container.innerHTML = `
${t('home.empty')}
` + return + } + + const positions = computePositions(rounds.length) + const totalW = PL.PADDING * 2 + PL.COLS * PL.NODE_W + (PL.COLS - 1) * PL.GAP_X + const rows = Math.ceil(rounds.length / PL.COLS) + const totalH = PL.PADDING * 2 + rows * PL.NODE_H + (rows - 1) * (PL.GAP_Y + PL.TURN_H) + + let svgPaths = '' + for (let i = 0; i < rounds.length - 1; i++) { + svgPaths += buildConnector(positions[i], positions[i + 1]) + } + + let nodesHtml = '' + rounds.forEach((r, idx) => { + nodesHtml += renderNodeCard(r, session, positions[idx]) + }) + + _scale = 1; _tx = 0; _ty = 0 + + container.innerHTML = ` +
+
+ + + +
+
+ + ${svgPaths} + + ${nodesHtml} +
+
+
+
+
` + + const vp = document.getElementById('pl-viewport') + vp.addEventListener('wheel', onWheel, { passive: false }) + vp.addEventListener('mousedown', onDragStart) + window.addEventListener('mousemove', onDragMove) + window.addEventListener('mouseup', onDragEnd) + + setTimeout(() => plFit(), 50) +} + +function computePositions(count) { + const positions = [] + for (let i = 0; i < count; i++) { + const row = Math.floor(i / PL.COLS) + const colInRow = i % PL.COLS + const reversed = row % 2 === 1 + const col = reversed ? (PL.COLS - 1 - colInRow) : colInRow + positions.push({ + x: PL.PADDING + col * (PL.NODE_W + PL.GAP_X), + y: PL.PADDING + row * (PL.NODE_H + PL.GAP_Y + PL.TURN_H), + row, col, reversed + }) + } + return positions +} + +function buildConnector(a, b) { + const ay = a.y + PL.NODE_H / 2 + const by = b.y + PL.NODE_H / 2 + const style = `fill="none" stroke="var(--border-2)" stroke-width="2" stroke-dasharray="6 4"` + + if (a.row === b.row) { + const x1 = a.reversed ? a.x : a.x + PL.NODE_W + const x2 = a.reversed ? b.x + PL.NODE_W : b.x + return `` + } + + // U-turn: exit side → down → enter side + const exitX = a.reversed ? a.x : a.x + PL.NODE_W + const enterX = b.reversed ? b.x + PL.NODE_W : b.x + const midY = (a.y + PL.NODE_H + b.y) / 2 + const sideX = a.reversed ? Math.min(a.x, b.x) - PL.GAP_X * 0.4 : Math.max(a.x + PL.NODE_W, b.x + PL.NODE_W) + PL.GAP_X * 0.4 + + return `` +} + +function renderNodeCard(r, session, pos) { + const hasSummary = !!selectLang(r.summary) + const verdict = r.verdict || 'unknown' + const isActive = session.status === 'active' && r.number === session.current_round + const phaseLabel = r.number === 0 ? t('node.setup') : (t(`phase.${r.phase}`) || r.phase) + + let stats = [] + if (r.duration_minutes) stats.push(`${r.duration_minutes}${t('unit.min')}`) + if (r.bitlesson_delta && r.bitlesson_delta !== 'none') stats.push('📚') + if (!hasSummary) stats.push('…') + + return ` +
+
+
+ R${r.number} + ${esc(phaseLabel)} +
+
+ + ${verdict} +
+
+ ${stats.length ? `
${stats.map(s => `${s}`).join('')}
` : ''} +
` +} + +// ─── Flyout Modal (expand from node to center) ─── + +function openFlyout(nodeEl, roundNum) { + if (_dragging) return + const session = window._currentSession + if (!session) return + const round = session.rounds.find(r => r.number === roundNum) + if (!round) return + + const overlay = document.getElementById('flyout-overlay') + const panel = document.getElementById('flyout-panel') + if (!overlay || !panel) return + + // Get node position on screen + const rect = nodeEl.getBoundingClientRect() + const vpRect = overlay.parentElement.getBoundingClientRect() + + // Set initial position to match node + panel.style.transition = 'none' + panel.style.left = (rect.left - vpRect.left) + 'px' + panel.style.top = (rect.top - vpRect.top) + 'px' + panel.style.width = rect.width + 'px' + panel.style.height = rect.height + 'px' + panel.style.opacity = '0.7' + panel.style.borderRadius = '14px' + panel.innerHTML = '' + + // Show overlay + overlay.classList.add('visible') + + // Animate to center + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const targetW = Math.min(720, vpRect.width - 80) + const targetH = Math.min(vpRect.height - 100, 600) + const targetL = (vpRect.width - targetW) / 2 + const targetT = (vpRect.height - targetH) / 2 + + panel.style.transition = 'all 400ms cubic-bezier(0.16, 1, 0.3, 1)' + panel.style.left = targetL + 'px' + panel.style.top = targetT + 'px' + panel.style.width = targetW + 'px' + panel.style.height = targetH + 'px' + panel.style.opacity = '1' + panel.style.borderRadius = '20px' + + // Fill content after animation starts + setTimeout(() => { + panel.innerHTML = buildFlyoutContent(round, session) + }, 150) + }) + }) +} + +function closeFlyout() { + const overlay = document.getElementById('flyout-overlay') + const panel = document.getElementById('flyout-panel') + if (!overlay || !panel) return + + panel.style.transition = 'all 300ms cubic-bezier(0.45, 0, 0.55, 1)' + panel.style.opacity = '0' + panel.style.transform = 'scale(0.9)' + + setTimeout(() => { + overlay.classList.remove('visible') + panel.style.transform = '' + panel.innerHTML = '' + }, 300) +} + +function buildFlyoutContent(round, session) { + const verdict = round.verdict || 'unknown' + const phaseLabel = round.number === 0 ? t('node.setup') : (t(`phase.${round.phase}`) || round.phase) + const summary = selectLang(round.summary) + const review = selectLang(round.review_result) + + const summaryHtml = summary ? marked.parse(summary) : `${t('detail.no_summary')}` + const reviewHtml = review ? marked.parse(review) : `${t('detail.no_review')}` + + let metaItems = ` + ${t('detail.phase')}: ${esc(phaseLabel)} + ${t('card.verdict')}: ${verdict}` + if (round.duration_minutes) metaItems += `${t('card.duration')}: ${round.duration_minutes} ${t('unit.min')}` + if (round.bitlesson_delta && round.bitlesson_delta !== 'none') metaItems += `${t('detail.bitlesson')}: ${round.bitlesson_delta} 📚` + if (round.task_progress != null) metaItems += `${t('detail.tasks')}: ${round.task_progress}/${session.tasks_total || '?'}` + + return ` +
+
+ R${round.number} +

${t('card.round')} ${round.number}

+
+ +
+
${metaItems}
+
+
+

${t('detail.summary')}

+
${summaryHtml}
+
+
+

${t('detail.review')}

+
${reviewHtml}
+
+
` +} + +// ─── Zoom / Pan ─── +function applyTransform() { + const canvas = document.getElementById('pl-canvas') + if (canvas) canvas.style.transform = `translate(${_tx}px, ${_ty}px) scale(${_scale})` +} + +function plZoom(delta) { + _scale = Math.max(0.3, Math.min(2.5, _scale + delta)) + applyTransform() +} + +function plFit() { + const vp = document.getElementById('pl-viewport') + const canvas = document.getElementById('pl-canvas') + if (!vp || !canvas) return + const vpW = vp.clientWidth, vpH = vp.clientHeight + const cW = parseInt(canvas.style.width), cH = parseInt(canvas.style.height) + _scale = Math.min(vpW / cW, vpH / cH, 1) * 0.92 + _tx = (vpW - cW * _scale) / 2 + _ty = Math.max(8, (vpH - cH * _scale) / 2) + applyTransform() +} + +function onWheel(e) { + e.preventDefault() + const delta = e.deltaY > 0 ? -0.08 : 0.08 + const rect = e.currentTarget.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + const oldScale = _scale + _scale = Math.max(0.3, Math.min(2.5, _scale + delta)) + const ratio = _scale / oldScale + _tx = mx - ratio * (mx - _tx) + _ty = my - ratio * (my - _ty) + applyTransform() +} + +function onDragStart(e) { + if (e.target.closest('.pl-node') || e.target.closest('.pl-ctrl-btn')) return + _dragging = true + _dragStartX = e.clientX; _dragStartY = e.clientY + _dragTx = _tx; _dragTy = _ty + e.currentTarget.style.cursor = 'grabbing' +} + +function onDragMove(e) { + if (!_dragging) return + _tx = _dragTx + (e.clientX - _dragStartX) + _ty = _dragTy + (e.clientY - _dragStartY) + applyTransform() +} + +function onDragEnd() { + if (!_dragging) return + _dragging = false + const vp = document.getElementById('pl-viewport') + if (vp) vp.style.cursor = '' +} + +function esc(str) { + const d = document.createElement('div') + d.textContent = str || '' + return d.innerHTML +} From 637291b8e0155b53755cc42d8347ad63af545a0e Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 12:39:21 +0800 Subject: [PATCH 2/7] feat: interactive viz dashboard prompt on RLCR loop start When starting an RLCR loop, if the viz dashboard is available: - setup-rlcr-loop.sh outputs VIZ_AVAILABLE/VIZ_PROJECT markers - start-rlcr-loop.md instructs Claude to ask the user via AskUserQuestion whether to open the dashboard - "Yes" launches the dashboard immediately - "No" prints a hint about /humanize:viz start for later use Also adds viz-start.sh to allowed-tools in start-rlcr-loop.md so Claude can execute it when the user accepts. Signed-off-by: Chao Liu --- commands/start-rlcr-loop.md | 13 +++++++++++++ scripts/setup-rlcr-loop.sh | 16 ++++------------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/commands/start-rlcr-loop.md b/commands/start-rlcr-loop.md index f24fb156..f5ae41ca 100644 --- a/commands/start-rlcr-loop.md +++ b/commands/start-rlcr-loop.md @@ -3,6 +3,7 @@ description: "Start iterative loop with Codex review" argument-hint: "[path/to/plan.md | --plan-file path/to/plan.md] [--max N] [--codex-model MODEL:EFFORT] [--codex-timeout SECONDS] [--track-plan-file] [--push-every-round] [--base-branch BRANCH] [--full-review-round N] [--skip-impl] [--claude-answer-codex] [--agent-teams] [--yolo] [--skip-quiz] [--privacy]" allowed-tools: - "Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-rlcr-loop.sh:*)" + - "Bash(${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-start.sh:*)" - "Read" - "Task" - "AskUserQuestion" @@ -114,6 +115,18 @@ If the pre-check passed (or was skipped), and the quiz passed (or was skipped or "${CLAUDE_PLUGIN_ROOT}/scripts/setup-rlcr-loop.sh" $ARGUMENTS ``` +### Viz Dashboard Offer + +After the setup script completes, check its output for the marker `VIZ_AVAILABLE=`. If found: + +1. Use `AskUserQuestion` to ask: "A web visualization dashboard is available for this RLCR session. Would you like to open it?" + - Option 1: "Yes, open dashboard" — Run the viz start script shown in the `VIZ_AVAILABLE` line, passing the project path from the `VIZ_PROJECT` line + - Option 2: "No, skip" — Print: "You can open the dashboard later with: `/humanize:viz start`" + +If the marker is not found (viz not available), skip this step silently. + +--- + This command starts an iterative development loop where: 1. You execute the implementation plan with task-tag routing diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 2904a060..89ccf535 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -1500,21 +1500,13 @@ To cancel: /humanize:cancel-rlcr-loop EOF fi -# ─── Viz Dashboard Prompt ─── -# Offer to launch the web visualization dashboard +# ─── Viz Dashboard Availability Marker ─── +# Output a marker that the command handler can detect to prompt the user VIZ_SCRIPT="$SCRIPT_DIR/../viz/scripts/viz-start.sh" if [[ -f "$VIZ_SCRIPT" ]] && command -v tmux &>/dev/null && command -v python3 &>/dev/null; then echo "" - echo "[Viz] Web dashboard is available for this RLCR session." - echo "[Viz] Launch with: bash \"$VIZ_SCRIPT\" \"$PROJECT_ROOT\"" - echo "[Viz] Or run: /humanize:viz start" - echo "" - - # Auto-start if viz_auto_start is set in config - if [[ "${VIZ_AUTO_START:-false}" == "true" ]]; then - echo "[Viz] Auto-starting dashboard..." - bash "$VIZ_SCRIPT" "$PROJECT_ROOT" 2>/dev/null & - fi + echo "VIZ_AVAILABLE=$VIZ_SCRIPT" + echo "VIZ_PROJECT=$PROJECT_ROOT" fi # Output the initial prompt From a918c613fca1f4971729ff149157e980b37060e3 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 14:46:09 +0800 Subject: [PATCH 3/7] feat: add upstream feedback button in session sidebar When viewing any RLCR session, the right sidebar now has an "Upstream Feedback" section with two buttons: - Preview Issue: opens a modal showing the sanitized issue content (title + body) in issue #62 format before submission - Submit to GitHub: sends the sanitized report to humania-org/humanize via `gh issue create` The flow: 1. Preview shows the taxonomy-generated content (no project data) 2. If sanitization warnings exist, content is redacted and Submit button is hidden 3. On successful submission, shows the issue URL and disables buttons to prevent duplicates 4. If gh is not available, offers a copy button as fallback Signed-off-by: Chao Liu --- viz/static/js/actions.js | 84 ++++++++++++++++++++++++++++++++++++++++ viz/static/js/app.js | 18 ++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js index 0ecaa5e7..6c4d2cf0 100644 --- a/viz/static/js/actions.js +++ b/viz/static/js/actions.js @@ -110,6 +110,90 @@ function copyToClipboard(text) { }) } +// ─── Sidebar Issue Submission ─── +async function sidebarPreviewIssue(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + if (resultEl) resultEl.innerHTML = `Loading preview...` + + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) { + if (resultEl) resultEl.innerHTML = `No methodology report available for this session.` + return + } + + const data = await res.json() + + // Check for warnings + const w = data.warnings || {} + const hasWarnings = data.requires_review || Object.keys(w).length > 0 + + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +
+

Issue Preview

+ → humania-org/humanize +
+ ${hasWarnings ? ` +
+ ⚠ Sanitization warnings detected. Content has been redacted.
+ ${Object.entries(w).map(([c, n]) => `• ${esc(c)}: ${n}`).join('')} +
` : ''} +
+
Title
+ ${esc(data.title)} +
+
+
Body
+
+ ${marked.parse(data.body)} +
+
+ ` + document.getElementById('modal-overlay').classList.add('visible') + if (resultEl) resultEl.innerHTML = '' +} + +async function sidebarSendIssue(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + if (resultEl) resultEl.innerHTML = `Submitting...` + + const res = await fetch(`/api/sessions/${sessionId}/github-issue`, { method: 'POST' }) + const data = await res.json() + + if (res.ok && data.url) { + if (resultEl) resultEl.innerHTML = ` +
+ ✓ Issue created
+ ${data.url} +
` + // Disable buttons after successful submission + const actionsEl = document.getElementById('sidebar-gh-actions') + if (actionsEl) actionsEl.innerHTML = `
✓ Submitted
` + } else if (data.manual) { + window._issuePayload = `Title: ${data.title || ''}\n\n${data.body || ''}` + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error)}
+ +
` + } else if (data.warnings) { + if (resultEl) resultEl.innerHTML = ` +
+ ⚠ Sanitization check failed
+ ${Object.entries(data.warnings).map(([c, n]) => `${c}: ${n}`).join(', ')} +
` + } else { + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error || 'Submission failed')} +
` + } +} + // ─── Plan Viewer ─── async function showPlanViewer(sessionId) { const res = await fetch(`/api/sessions/${sessionId}/plan`) diff --git a/viz/static/js/app.js b/viz/static/js/app.js index 55956bca..8fdebb1b 100644 --- a/viz/static/js/app.js +++ b/viz/static/js/app.js @@ -294,7 +294,23 @@ function renderSessionSidebar(s) {
${acDone}/${acTotal}
- ` : ''}` + ` : ''} + + ` } function renderGoalBar(session) { From 6771bda603c1a186350b039af345ba3884d51cd9 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 15:38:46 +0800 Subject: [PATCH 4/7] feat: auto-generate methodology report via local Claude CLI When clicking "Preview Issue" or "Submit to GitHub" in the session sidebar, if no methodology-analysis-report.md exists yet: 1. Frontend calls POST /api/sessions//generate-report 2. Backend collects all round summaries and review results 3. Invokes `claude -p --model sonnet` with a sanitization prompt matching methodology-analysis-prompt.md rules 4. Saves the generated report to the session directory 5. Frontend shows a spinner during generation (~30-60s) 6. Once complete, proceeds to preview or submit flow The prompt enforces: - Zero project-specific information (file paths, function names, branch names, business terms, code snippets) - Issue #62 format (Context, Observations, Suggested Improvements table, Quantitative Summary table) - Pure methodology perspective Signed-off-by: Chao Liu --- viz/server/app.py | 120 +++++++++++++++++++++++++++++++++++++++ viz/static/css/theme.css | 15 +++++ viz/static/js/actions.js | 61 ++++++++++++++++++++ viz/static/js/app.js | 4 +- 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/viz/server/app.py b/viz/server/app.py index ec8fb942..d5212f90 100644 --- a/viz/server/app.py +++ b/viz/server/app.py @@ -182,6 +182,126 @@ def api_analytics(): return jsonify(analytics) +@app.route('/api/sessions//generate-report', methods=['POST']) +def api_generate_report(session_id): + """Generate a methodology analysis report by invoking local Claude CLI.""" + session_dir = _get_session_dir(session_id) + if not session_dir: + abort(404) + + report_path = os.path.join(session_dir, 'methodology-analysis-report.md') + + # If report already exists, just return it + if os.path.exists(report_path) and os.path.getsize(report_path) > 0: + with open(report_path, 'r', encoding='utf-8') as f: + return jsonify({'status': 'exists', 'content': f.read()}) + + # Collect round summaries and review results + summaries = [] + import glob as _glob + for sf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))): + try: + with open(sf, 'r', encoding='utf-8') as f: + summaries.append(f'--- {os.path.basename(sf)} ---\n{f.read()}') + except (PermissionError, OSError): + pass + + reviews = [] + for rf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))): + try: + with open(rf, 'r', encoding='utf-8') as f: + reviews.append(f'--- {os.path.basename(rf)} ---\n{f.read()}') + except (PermissionError, OSError): + pass + + if not summaries and not reviews: + return jsonify({'error': 'No round data to analyze'}), 400 + + # Build the analysis prompt + prompt = f"""Analyze the following RLCR development records from a PURE METHODOLOGY perspective. + +CRITICAL SANITIZATION RULES — your output MUST NOT contain: +- File paths, directory paths, or module paths +- Function names, variable names, class names, or method names +- Branch names, commit hashes, or git identifiers +- Business domain terms, product names, or feature names +- Code snippets or code fragments of any kind +- Raw error messages or stack traces +- Project-specific URLs or endpoints +- Any information that could identify the specific project + +Focus areas: +- Iteration efficiency: Were rounds productive or repetitive? +- Feedback loop quality: Did reviewer feedback lead to improvements? +- Stagnation patterns: Were there signs of going in circles? +- Review effectiveness: Did reviews catch real issues or create false positives? +- Plan-to-execution alignment: Did execution follow the plan or drift? +- Round count vs. progress ratio: Was the number of rounds proportional to progress? +- Communication clarity: Were summaries and reviews clear and actionable? + +Output format: Write a structured markdown report following this exact structure: + +## Context + + +## Observations + + +## Suggested Improvements +| # | Suggestion | Mechanism | +|---|-----------|-----------| + + +## Quantitative Summary +| Metric | Value | +|--------|-------| + + +--- ROUND SUMMARIES --- +{chr(10).join(summaries[:10])} + +--- REVIEW RESULTS --- +{chr(10).join(reviews[:10])} +""" + + # Invoke Claude CLI in pipe mode + try: + result = subprocess.run( + ['claude', '-p', '--model', 'sonnet', '--output-format', 'text'], + input=prompt, + capture_output=True, + text=True, + timeout=120, + cwd=PROJECT_DIR, + ) + + if result.returncode != 0: + return jsonify({ + 'error': f'Claude CLI failed (exit {result.returncode})', + 'stderr': result.stderr[-500:] if result.stderr else '', + }), 500 + + report_content = result.stdout.strip() + if not report_content: + return jsonify({'error': 'Claude returned empty response'}), 500 + + # Save the report + with open(report_path, 'w', encoding='utf-8') as f: + f.write(report_content) + + # Invalidate session cache so the report is picked up + _invalidate_cache(session_id) + + return jsonify({'status': 'generated', 'content': report_content}) + + except FileNotFoundError: + return jsonify({'error': 'Claude CLI not found. Install Claude Code to generate reports.'}), 500 + except subprocess.TimeoutExpired: + return jsonify({'error': 'Claude CLI timed out (120s). Try again or reduce session size.'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + def _find_cancel_script(): """Resolve cancel-rlcr-loop.sh from plugin layout or env.""" # Check env override first diff --git a/viz/static/css/theme.css b/viz/static/css/theme.css index 901af5d4..e14130e3 100644 --- a/viz/static/css/theme.css +++ b/viz/static/css/theme.css @@ -402,6 +402,21 @@ a:hover { color: var(--accent-hover); } 100% { box-shadow: 0 0 0 0 transparent; } } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border-2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + @keyframes fade-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js index 6c4d2cf0..9770ac3a 100644 --- a/viz/static/js/actions.js +++ b/viz/static/js/actions.js @@ -110,6 +110,67 @@ function copyToClipboard(text) { }) } +// ─── Generate Report (calls local Claude CLI) ─── +async function ensureReport(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + + // Try sanitized-issue first — if it works, report exists + const check = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (check.ok) { + const data = await check.json() + if (!data.requires_review || data.body !== '[REDACTED — outbound payload failed validation.]') { + return true + } + } + + // No report — generate one via Claude CLI + if (resultEl) resultEl.innerHTML = ` +
+
+ + Generating methodology report via Claude... +
+
+ This may take 30-60 seconds. Analyzing round summaries and reviews. +
+
` + + try { + const res = await fetch(`/api/sessions/${sessionId}/generate-report`, { method: 'POST' }) + const data = await res.json() + + if (res.ok && (data.status === 'generated' || data.status === 'exists')) { + if (resultEl) resultEl.innerHTML = ` +
+ ✓ Report generated successfully +
` + return true + } else { + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error || 'Failed to generate report')} +
` + return false + } + } catch (e) { + if (resultEl) resultEl.innerHTML = ` +
+ Network error: ${esc(e.message)} +
` + return false + } +} + +async function sidebarGenerateAndPreview(sessionId) { + const ok = await ensureReport(sessionId) + if (ok) await sidebarPreviewIssue(sessionId) +} + +async function sidebarGenerateAndSend(sessionId) { + const ok = await ensureReport(sessionId) + if (ok) await sidebarSendIssue(sessionId) +} + // ─── Sidebar Issue Submission ─── async function sidebarPreviewIssue(sessionId) { const resultEl = document.getElementById('sidebar-gh-result') diff --git a/viz/static/js/app.js b/viz/static/js/app.js index 8fdebb1b..7fcd09ad 100644 --- a/viz/static/js/app.js +++ b/viz/static/js/app.js @@ -302,10 +302,10 @@ function renderSessionSidebar(s) { Submit a sanitized methodology report to humania-org/humanize to help improve the RLCR process. From 80fdc00eab57ddd3df1c1cec762e07d7d668c5b3 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 16:00:35 +0800 Subject: [PATCH 5/7] test: add comprehensive viz dashboard test suite 38 tests covering: - Shell script syntax validation (3) - Python module syntax validation (5) - Parser functionality: parse_session, canonical rounds, goal tracker, Completed and Verified parsing, list_sessions, is_valid_session, malformed session skip (6) - Analyzer: empty sessions, basic statistics, verdict distribution excludes non-reviewed rounds (3) - Exporter: Markdown generation, bilingual {zh,en} dict handling (2) - Integration markers: VIZ_AVAILABLE/VIZ_PROJECT in setup script, viz-stop in cancel script, viz prompt in start command (5) - Command & skill definitions (3) - Static asset existence + i18n English-only check + requirements (11) Signed-off-by: Chao Liu --- tests/test-viz.sh | 443 ++++++++++++++++++++++++++++++++++++++ viz/server/parser.py | 11 +- viz/static/index.html | 1 + viz/static/js/actions.js | 6 +- viz/static/js/app.js | 4 +- viz/static/js/i18n.js | 7 + viz/static/js/pipeline.js | 4 +- 7 files changed, 467 insertions(+), 9 deletions(-) create mode 100755 tests/test-viz.sh diff --git a/tests/test-viz.sh b/tests/test-viz.sh new file mode 100755 index 00000000..abc02edf --- /dev/null +++ b/tests/test-viz.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# +# Tests for the Humanize Viz dashboard functionality +# +# Tests cover: +# - viz-start.sh / viz-stop.sh / viz-status.sh script behavior +# - Python parser module (syntax + basic functionality) +# - Python analyzer module +# - Python exporter module +# - Sanitized issue generation +# - Setup script viz marker output +# - Cancel script viz stop integration +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test-helpers.sh" + +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VIZ_DIR="$PLUGIN_ROOT/viz" +SERVER_DIR="$VIZ_DIR/server" + +echo "========================================" +echo "Humanize Viz Dashboard Tests" +echo "========================================" + +# ─── Pre-check ─── +if ! command -v python3 &>/dev/null; then + echo "SKIP: python3 not available" + exit 0 +fi + +setup_test_dir + +# ======================================== +# Test Group 1: Shell Script Validation +# ======================================== +echo "" +echo "Test Group 1: Shell Script Syntax" + +for script in viz-start.sh viz-stop.sh viz-status.sh; do + if bash -n "$VIZ_DIR/scripts/$script" 2>/dev/null; then + pass "Shell syntax valid: $script" + else + fail "Shell syntax invalid: $script" + fi +done + +# ======================================== +# Test Group 2: Python Module Syntax +# ======================================== +echo "" +echo "Test Group 2: Python Module Syntax" + +for module in parser.py analyzer.py exporter.py app.py watcher.py; do + if python3 -m py_compile "$SERVER_DIR/$module" 2>/dev/null; then + pass "Python syntax valid: $module" + else + fail "Python syntax invalid: $module" + fi +done + +# ======================================== +# Test Group 3: Parser Tests +# ======================================== +echo "" +echo "Test Group 3: Parser Functionality" + +# Create a mock RLCR session +MOCK_PROJECT="$TEST_DIR/project" +MOCK_SESSION="$MOCK_PROJECT/.humanize/rlcr/2026-01-01_12-00-00" +mkdir -p "$MOCK_SESSION" + +# Create state.md +cat > "$MOCK_SESSION/state.md" << 'STATE' +--- +current_round: 2 +max_iterations: 42 +plan_file: plan.md +start_branch: main +base_branch: main +codex_model: gpt-5.4 +codex_effort: high +started_at: 2026-01-01T12:00:00Z +--- +STATE + +# Create goal-tracker.md +cat > "$MOCK_SESSION/goal-tracker.md" << 'GT' +## IMMUTABLE SECTION + +### Ultimate Goal +Build a test feature. + +### Acceptance Criteria + +- AC-1: First criterion +- AC-2: Second criterion + +--- + +## MUTABLE SECTION + +### Plan Version: 1 (Updated: Round 0) + +#### Active Tasks +| Task | Target AC | Status | Tag | Owner | Notes | +|------|-----------|--------|-----|-------|-------| +| task1 | AC-1 | completed | coding | claude | Done | +| task2 | AC-2 | in_progress | coding | claude | WIP | + +### Completed and Verified +| AC | Task | Completed Round | Verified Round | Evidence | +|----|------|-----------------|----------------|----------| +| AC-1 | task1 | 1 | 1 | Tests pass | + +### Explicitly Deferred +| Task | Original AC | Deferred Since | Justification | When to Reconsider | +|------|-------------|----------------|---------------|-------------------| +GT + +# Create round summaries +cat > "$MOCK_SESSION/round-0-summary.md" << 'R0' +# Round 0 Summary +## What Was Implemented +Initial setup completed. 2/4 tasks done. +## BitLesson Delta +Action: none +R0 + +cat > "$MOCK_SESSION/round-1-summary.md" << 'R1' +# Round 1 Summary +## What Was Implemented +Implemented main feature. +## BitLesson Delta +Action: add +R1 + +# Create review result +cat > "$MOCK_SESSION/round-0-review-result.md" << 'RR0' +# Round 0 Review +Mainline Progress Verdict: ADVANCED +The implementation is progressing well. +RR0 + +# Test parser +PARSER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from parser import parse_session, list_sessions, is_valid_session + +# Test is_valid_session +assert is_valid_session('$MOCK_SESSION'), 'should be valid session' + +# Test parse_session +s = parse_session('$MOCK_SESSION') +assert s['id'] == '2026-01-01_12-00-00', f'id mismatch: {s[\"id\"]}' +assert s['status'] == 'active', f'status: {s[\"status\"]}' +assert s['current_round'] == 2, f'round: {s[\"current_round\"]}' +assert s['max_iterations'] == 42 +assert s['plan_file'] == 'plan.md' +assert s['start_branch'] == 'main' +assert s['codex_model'] == 'gpt-5.4' + +# Rounds: should have 3 (0, 1, 2) even though round 2 has no summary +assert len(s['rounds']) == 3, f'expected 3 rounds, got {len(s[\"rounds\"])}' +assert s['rounds'][0]['number'] == 0 +assert s['rounds'][2]['number'] == 2 + +# Round 0 should have summary content +r0_summary = s['rounds'][0]['summary'] +assert r0_summary is not None and (isinstance(r0_summary, dict) or isinstance(r0_summary, str)), 'round 0 should have summary' + +# Round 2 should have null summary (no file) +r2_summary = s['rounds'][2]['summary'] +if isinstance(r2_summary, dict): + assert r2_summary.get('en') is None and r2_summary.get('zh') is None, 'round 2 summary should be null' + +# Verdict from review +assert s['rounds'][0]['verdict'] == 'advanced', f'verdict: {s[\"rounds\"][0][\"verdict\"]}' + +# Goal tracker +gt = s['goal_tracker'] +assert gt is not None +assert len(gt['acceptance_criteria']) == 2 +assert gt['acceptance_criteria'][0]['id'] == 'AC-1' + +# Completed and Verified parsing +assert len(gt['completed_verified']) == 1 +assert gt['completed_verified'][0]['ac'] == 'AC-1' + +# AC status from completed table +assert any(ac['status'] == 'completed' for ac in gt['acceptance_criteria']), 'AC-1 should be completed' + +# Task counts +assert s['tasks_total'] == 3, f'tasks_total: {s[\"tasks_total\"]}' # 2 active + 1 completed +assert s['tasks_done'] == 1, f'tasks_done: {s[\"tasks_done\"]}' + +# Test list_sessions +sessions = list_sessions('$MOCK_PROJECT') +assert len(sessions) == 1 +assert sessions[0]['id'] == '2026-01-01_12-00-00' + +print('ALL_PARSER_TESTS_PASSED') +" 2>&1) + +if echo "$PARSER_OUTPUT" | grep -q "ALL_PARSER_TESTS_PASSED"; then + pass "Parser: parse_session with full mock data" + pass "Parser: canonical round indices (0..current_round)" + pass "Parser: goal tracker with Completed and Verified" + pass "Parser: list_sessions" + pass "Parser: is_valid_session" +else + fail "Parser tests" "" "$PARSER_OUTPUT" +fi + +# Test malformed session skip +MALFORMED_SESSION="$MOCK_PROJECT/.humanize/rlcr/2026-01-01_13-00-00" +mkdir -p "$MALFORMED_SESSION" +echo "garbage" > "$MALFORMED_SESSION/readme.txt" + +SKIP_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from parser import is_valid_session +assert not is_valid_session('$MALFORMED_SESSION'), 'should not be valid' +print('SKIP_OK') +" 2>&1) + +if echo "$SKIP_OUTPUT" | grep -q "SKIP_OK"; then + pass "Parser: skips malformed session (no state.md)" +else + fail "Parser: malformed session skip" "" "$SKIP_OUTPUT" +fi + +# ======================================== +# Test Group 4: Analyzer Tests +# ======================================== +echo "" +echo "Test Group 4: Analyzer" + +cd "$PLUGIN_ROOT" +ANALYZER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from analyzer import compute_analytics + +# Empty +result = compute_analytics([]) +assert result['overview']['total_sessions'] == 0 +assert result['overview']['completion_rate'] == 0 + +# With mock session +mock = { + 'id': '2026-01-01_12-00-00', + 'current_round': 3, + 'status': 'complete', + 'ac_done': 2, 'ac_total': 4, + 'rounds': [ + {'number': 0, 'verdict': 'advanced', 'review_result': 'some review', 'bitlesson_delta': 'add', 'phase': 'implementation', 'p_issues': {}, 'duration_minutes': 10}, + {'number': 1, 'verdict': 'advanced', 'review_result': 'review 2', 'bitlesson_delta': 'none', 'phase': 'implementation', 'p_issues': {'P1': 1}, 'duration_minutes': 15}, + {'number': 2, 'verdict': 'complete', 'review_result': 'final', 'bitlesson_delta': 'none', 'phase': 'code_review', 'p_issues': {}, 'duration_minutes': 5}, + ] +} +result = compute_analytics([mock]) +assert result['overview']['total_sessions'] == 1 +assert result['overview']['completed_sessions'] == 1 +assert result['overview']['completion_rate'] == 100.0 + +# Verdict distribution should not include rounds without review_result +vd = result['verdict_distribution'] +assert 'advanced' in vd +assert vd['advanced'] == 2 +assert vd.get('unknown', 0) == 0, 'unknown should not appear for reviewed rounds' + +print('ANALYZER_OK') +" 2>&1) + +if echo "$ANALYZER_OUTPUT" | grep -q "ANALYZER_OK"; then + pass "Analyzer: empty sessions" + pass "Analyzer: basic statistics" + pass "Analyzer: verdict distribution excludes non-reviewed rounds" +else + fail "Analyzer tests" "" "$ANALYZER_OUTPUT" +fi + +# ======================================== +# Test Group 5: Exporter Tests +# ======================================== +echo "" +echo "Test Group 5: Exporter" + +EXPORTER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from exporter import export_session_markdown + +mock = { + 'id': '2026-01-01_12-00-00', + 'status': 'complete', + 'current_round': 2, + 'plan_file': 'plan.md', + 'start_branch': 'main', + 'started_at': '2026-01-01T12:00:00Z', + 'codex_model': 'gpt-5.4', + 'last_verdict': 'advanced', + 'ac_total': 2, 'ac_done': 2, + 'rounds': [ + {'number': 0, 'phase': 'implementation', 'verdict': 'unknown', 'duration_minutes': None, + 'bitlesson_delta': 'none', 'summary': {'en': '# Round 0', 'zh': None}, 'review_result': {'en': None, 'zh': None}}, + {'number': 1, 'phase': 'implementation', 'verdict': 'advanced', 'duration_minutes': 15.0, + 'bitlesson_delta': 'add', 'summary': {'en': '# Round 1 done', 'zh': None}, 'review_result': {'en': 'ADVANCED', 'zh': None}}, + ], + 'goal_tracker': { + 'ultimate_goal': 'Test goal', + 'acceptance_criteria': [ + {'id': 'AC-1', 'description': 'First', 'status': 'completed'}, + {'id': 'AC-2', 'description': 'Second', 'status': 'completed'}, + ] + }, + 'methodology_report': {'en': '# Report', 'zh': None}, +} + +md = export_session_markdown(mock) +assert 'RLCR Session Report' in md +assert '2026-01-01_12-00-00' in md +assert 'Round 0' in md +assert 'Round 1 done' in md +assert 'AC-1' in md +assert '# Report' in md +assert isinstance(md, str), 'output must be string, not dict' + +print('EXPORTER_OK') +" 2>&1) + +if echo "$EXPORTER_OUTPUT" | grep -q "EXPORTER_OK"; then + pass "Exporter: generates valid Markdown from bilingual session" + pass "Exporter: handles {zh,en} dicts without TypeError" +else + fail "Exporter tests" "" "$EXPORTER_OUTPUT" +fi + +# ======================================== +# Test Group 6: Setup Script Viz Marker +# ======================================== +echo "" +echo "Test Group 6: Integration Markers" + +if grep -q "VIZ_AVAILABLE=" "$PLUGIN_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Setup script contains VIZ_AVAILABLE marker" +else + fail "Setup script missing VIZ_AVAILABLE marker" +fi + +if grep -q "VIZ_PROJECT=" "$PLUGIN_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Setup script contains VIZ_PROJECT marker" +else + fail "Setup script missing VIZ_PROJECT marker" +fi + +# Cancel script should stop viz +if grep -q "viz-stop.sh" "$PLUGIN_ROOT/scripts/cancel-rlcr-loop.sh"; then + pass "Cancel script calls viz-stop.sh" +else + fail "Cancel script missing viz-stop.sh call" +fi + +# Start-rlcr-loop.md should have viz instruction +if grep -q "VIZ_AVAILABLE" "$PLUGIN_ROOT/commands/start-rlcr-loop.md"; then + pass "Start command has viz dashboard prompt instruction" +else + fail "Start command missing viz prompt instruction" +fi + +if grep -q "viz-start.sh" "$PLUGIN_ROOT/commands/start-rlcr-loop.md"; then + pass "Start command allows viz-start.sh in tools" +else + fail "Start command missing viz-start.sh tool permission" +fi + +# ======================================== +# Test Group 7: Viz Command & Skill +# ======================================== +echo "" +echo "Test Group 7: Command & Skill" + +if [[ -f "$PLUGIN_ROOT/commands/viz.md" ]]; then + pass "Viz command definition exists" +else + fail "Viz command definition missing" +fi + +if [[ -f "$PLUGIN_ROOT/skills/humanize-viz/SKILL.md" ]]; then + pass "Viz skill definition exists" +else + fail "Viz skill definition missing" +fi + +if grep -q "start" "$PLUGIN_ROOT/commands/viz.md" && grep -q "stop" "$PLUGIN_ROOT/commands/viz.md" && grep -q "status" "$PLUGIN_ROOT/commands/viz.md"; then + pass "Viz command documents start/stop/status subcommands" +else + fail "Viz command missing subcommand documentation" +fi + +# ======================================== +# Test Group 8: Static Assets +# ======================================== +echo "" +echo "Test Group 8: Static Assets" + +for file in index.html css/theme.css css/layout.css js/app.js js/pipeline.js js/charts.js js/actions.js js/i18n.js; do + if [[ -f "$VIZ_DIR/static/$file" ]]; then + pass "Static file exists: $file" + else + fail "Static file missing: $file" + fi +done + +# Verify no hard-coded Chinese in i18n.js (UI should be English-only) +if ! grep -P '[\x{4e00}-\x{9fff}]' "$VIZ_DIR/static/js/i18n.js" >/dev/null 2>&1; then + pass "i18n.js contains no Chinese characters (English-only UI)" +else + fail "i18n.js should not contain Chinese characters" +fi + +# Requirements file +if [[ -f "$VIZ_DIR/server/requirements.txt" ]]; then + pass "Python requirements.txt exists" + if grep -q "flask" "$VIZ_DIR/server/requirements.txt"; then + pass "requirements.txt includes flask" + else + fail "requirements.txt missing flask" + fi +else + fail "Python requirements.txt missing" +fi + +# ======================================== +# Summary +# ======================================== + +print_test_summary "Humanize Viz Tests" diff --git a/viz/server/parser.py b/viz/server/parser.py index 25a28fc7..4bc186a6 100644 --- a/viz/server/parser.py +++ b/viz/server/parser.py @@ -250,10 +250,17 @@ def parse_session(session_dir): current_round = state.get('current_round', 0) - # Build rounds from canonical indices 0..current_round + # Discover the highest round index present on disk (review files may exceed current_round) + max_disk_round = current_round + for f in os.listdir(session_dir): + m = re.match(r'round-(\d+)-(?:summary|review-result)\.md$', f) + if m: + max_disk_round = max(max_disk_round, int(m.group(1))) + + # Build rounds from 0..max(current_round, highest on-disk round) rounds = [] prev_mtime = None - for rn in range(current_round + 1): + for rn in range(max_disk_round + 1): summary_file = os.path.join(session_dir, f'round-{rn}-summary.md') review_file = os.path.join(session_dir, f'round-{rn}-review-result.md') diff --git a/viz/static/index.html b/viz/static/index.html index 05ef7a67..a074a376 100644 --- a/viz/static/index.html +++ b/viz/static/index.html @@ -17,6 +17,7 @@ + diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js index 9770ac3a..f410841a 100644 --- a/viz/static/js/actions.js +++ b/viz/static/js/actions.js @@ -65,7 +65,7 @@ async function previewGitHubIssue(sessionId) {
${t('analysis.issue_body')}
- ${marked.parse(data.body)} + ${safeMd(data.body)}
` diff --git a/viz/static/js/i18n.js b/viz/static/js/i18n.js index 9199d356..0665fafa 100644 --- a/viz/static/js/i18n.js +++ b/viz/static/js/i18n.js @@ -91,3 +91,10 @@ function selectLang(content) { } return null } + +// Safe Markdown rendering — parse then sanitize to prevent XSS +function safeMd(text) { + if (!text) return '' + const html = marked.parse(text) + return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : html +} diff --git a/viz/static/js/pipeline.js b/viz/static/js/pipeline.js index 19c06afe..8e242c86 100644 --- a/viz/static/js/pipeline.js +++ b/viz/static/js/pipeline.js @@ -206,8 +206,8 @@ function buildFlyoutContent(round, session) { const summary = selectLang(round.summary) const review = selectLang(round.review_result) - const summaryHtml = summary ? marked.parse(summary) : `${t('detail.no_summary')}` - const reviewHtml = review ? marked.parse(review) : `${t('detail.no_review')}` + const summaryHtml = summary ? safeMd(summary) : `${t('detail.no_summary')}` + const reviewHtml = review ? safeMd(review) : `${t('detail.no_review')}` let metaItems = ` ${t('detail.phase')}: ${esc(phaseLabel)} From 2e96e06ec69096a614db3ad16288d35d6bbcf760 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 16:30:46 +0800 Subject: [PATCH 6/7] feat: active RLCR dynamic progress visualization When a session is active: 1. Current round node enhancements: - Sweeping gradient bar at top (horizontal scan animation) - Pulsing orange glow shadow - Live blinking dot indicator next to phase tag - Stronger border highlight 2. Ghost "next round" node: - Dashed orange border with breathing opacity animation - Shows "R{N+1} Next" with spinner - "Awaiting..." status text - Positioned as the next node in the snake path 3. Flowing edge animation: - The connector between current round and ghost node has animated dashes (stroke-dashoffset animation) - Orange colored to distinguish from static connectors All animations are CSS-only, no JavaScript timers. Signed-off-by: Chao Liu --- viz/static/css/layout.css | 76 +++++++++++++++++++++++++++++++++++++++ viz/static/js/pipeline.js | 53 +++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/viz/static/css/layout.css b/viz/static/css/layout.css index 7a502c24..6cf74fd6 100644 --- a/viz/static/css/layout.css +++ b/viz/static/css/layout.css @@ -290,6 +290,82 @@ .pl-node[data-verdict="complete"] { border-left: 4px solid var(--verdict-complete); } .pl-node[data-verdict="unknown"] { border-left: 4px solid var(--verdict-unknown); } +/* ─── Active Node Enhancements ─── */ +.pl-node.active-round { + border-color: var(--accent); + box-shadow: 0 0 20px var(--accent-glow), var(--shadow-md); + animation: pulse-ring 2.5s var(--ease-in-out) infinite; +} + +.node-active-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--bg-3); + overflow: hidden; + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.node-active-bar-fill { + height: 100%; + width: 40%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + animation: active-bar-sweep 2s ease-in-out infinite; +} + +@keyframes active-bar-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +.node-live-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: live-blink 1.2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes live-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} + +/* ─── Ghost "In Progress" Node ─── */ +.pl-ghost-node { + border: 2px dashed var(--accent) !important; + border-left: 4px dashed var(--accent) !important; + background: var(--bg-glow) !important; + opacity: 0.7; + cursor: default !important; + animation: ghost-breathe 3s ease-in-out infinite; +} + +.pl-ghost-node:hover { + border-color: var(--accent) !important; + box-shadow: none !important; + transform: none !important; +} + +@keyframes ghost-breathe { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.8; } +} + +/* ─── Active Edge (flowing dash animation) ─── */ +.pl-edge-active { + animation: edge-flow 1s linear infinite; +} + +@keyframes edge-flow { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -20; } +} + .node-header { display: flex; diff --git a/viz/static/js/pipeline.js b/viz/static/js/pipeline.js index 8e242c86..6cec5b07 100644 --- a/viz/static/js/pipeline.js +++ b/viz/static/js/pipeline.js @@ -21,14 +21,18 @@ function renderPipeline(container, session) { return } - const positions = computePositions(rounds.length) + const isActive = session.status === 'active' + // Total node count: rounds + 1 ghost node for active sessions + const totalNodes = isActive ? rounds.length + 1 : rounds.length + const positions = computePositions(totalNodes) const totalW = PL.PADDING * 2 + PL.COLS * PL.NODE_W + (PL.COLS - 1) * PL.GAP_X - const rows = Math.ceil(rounds.length / PL.COLS) + const rows = Math.ceil(totalNodes / PL.COLS) const totalH = PL.PADDING * 2 + rows * PL.NODE_H + (rows - 1) * (PL.GAP_Y + PL.TURN_H) let svgPaths = '' - for (let i = 0; i < rounds.length - 1; i++) { - svgPaths += buildConnector(positions[i], positions[i + 1]) + for (let i = 0; i < totalNodes - 1; i++) { + const isLastEdge = isActive && i === rounds.length - 1 + svgPaths += buildConnector(positions[i], positions[i + 1], isLastEdge) } let nodesHtml = '' @@ -36,6 +40,12 @@ function renderPipeline(container, session) { nodesHtml += renderNodeCard(r, session, positions[idx]) }) + // Ghost "in progress" node for active sessions + if (isActive) { + const ghostPos = positions[rounds.length] + nodesHtml += renderGhostNode(session, ghostPos) + } + _scale = 1; _tx = 0; _ty = 0 container.innerHTML = ` @@ -81,10 +91,12 @@ function computePositions(count) { return positions } -function buildConnector(a, b) { +function buildConnector(a, b, animated) { const ay = a.y + PL.NODE_H / 2 const by = b.y + PL.NODE_H / 2 - const style = `fill="none" stroke="var(--border-2)" stroke-width="2" stroke-dasharray="6 4"` + const cls = animated ? 'class="pl-edge-active"' : '' + const color = animated ? 'var(--accent)' : 'var(--border-2)' + const style = `fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6 4" ${cls}` if (a.row === b.row) { const x1 = a.reversed ? a.x : a.x + PL.NODE_W @@ -92,7 +104,6 @@ function buildConnector(a, b) { return `` } - // U-turn: exit side → down → enter side const exitX = a.reversed ? a.x : a.x + PL.NODE_W const enterX = b.reversed ? b.x + PL.NODE_W : b.x const midY = (a.y + PL.NODE_H + b.y) / 2 @@ -112,14 +123,22 @@ function renderNodeCard(r, session, pos) { if (r.bitlesson_delta && r.bitlesson_delta !== 'none') stats.push('📚') if (!hasSummary) stats.push('…') + // Active node gets a progress indicator + const activeIndicator = isActive ? ` +
+
+
` : '' + return `
+ ${activeIndicator}
R${r.number} ${esc(phaseLabel)} + ${isActive ? '' : ''}
@@ -130,6 +149,26 @@ function renderNodeCard(r, session, pos) {
` } +function renderGhostNode(session, pos) { + const nextRound = session.current_round + 1 + return ` +
+
+
+ R${nextRound} + Next +
+
+ +
+
+
+ Awaiting... +
+
` +} + // ─── Flyout Modal (expand from node to center) ─── function openFlyout(nodeEl, roundNum) { From 1b575fe70aeae334fc72aa04757a144a92b3d623 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Thu, 2 Apr 2026 17:08:11 +0800 Subject: [PATCH 7/7] feat: multi-project switcher + restart + remove chart panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-project support: - Backend: /api/projects, /api/projects/switch, /api/projects/add, /api/projects/remove endpoints - Projects saved to ~/.humanize/viz-projects.json - Switch project dynamically (restarts watcher, clears cache) - Home page shows project bar with current project name/path, Switch dropdown for other projects, + Add button Restart command: - viz-restart.sh: stop + start in one step - /humanize:viz restart subcommand added Analytics cleanup: - Removed 6 Chart.js panels (Rounds/Duration/Verdicts/P-Issues/ FirstComplete/BitLesson) — kept stats overview + timeline + table - Session Comparison table defaults to time descending (newest first) Signed-off-by: Chao Liu --- commands/viz.md | 10 +-- viz/scripts/viz-restart.sh | 13 ++++ viz/scripts/viz-start.sh | 30 ++++----- viz/scripts/viz-status.sh | 35 +++++----- viz/server/app.py | 133 +++++++++++++++++++++++++++++++++++-- viz/static/css/layout.css | 50 ++++++++++++++ viz/static/index.html | 10 +-- viz/static/js/actions.js | 52 +++++++++++++++ viz/static/js/app.js | 116 +++++++++++++++++++++----------- viz/static/js/charts.js | 5 +- viz/static/js/pipeline.js | 2 +- 11 files changed, 367 insertions(+), 89 deletions(-) create mode 100755 viz/scripts/viz-restart.sh diff --git a/commands/viz.md b/commands/viz.md index 1e265ca4..644760b7 100644 --- a/commands/viz.md +++ b/commands/viz.md @@ -12,6 +12,7 @@ Manage the local web visualization dashboard for RLCR loop sessions. - `start` — Launch the dashboard server (creates venv on first run, opens browser) - `stop` — Stop the dashboard server +- `restart` — Restart the dashboard server - `status` — Check if the dashboard server is running ## Implementation @@ -25,10 +26,11 @@ PROJECT_DIR="$(pwd)" # Route subcommand case "$1" in - start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;; - stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;; - status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; - *) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; + start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;; + stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;; + restart) bash "$VIZ_DIR/viz-restart.sh" "$PROJECT_DIR" ;; + status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; + *) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; esac ``` diff --git a/viz/scripts/viz-restart.sh b/viz/scripts/viz-restart.sh new file mode 100755 index 00000000..738338aa --- /dev/null +++ b/viz/scripts/viz-restart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Restart the Humanize Viz dashboard server. +# Usage: viz-restart.sh [--project ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +bash "$SCRIPT_DIR/viz-stop.sh" "$PROJECT_DIR" 2>/dev/null || true +sleep 1 +exec bash "$SCRIPT_DIR/viz-start.sh" "$PROJECT_DIR" diff --git a/viz/scripts/viz-start.sh b/viz/scripts/viz-start.sh index 1b1d06b1..01370b3a 100755 --- a/viz/scripts/viz-start.sh +++ b/viz/scripts/viz-start.sh @@ -24,27 +24,21 @@ if [[ ! -d "$HUMANIZE_DIR" ]]; then exit 1 fi -# Check if already running -if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then - if [[ -f "$PORT_FILE" ]]; then - port=$(cat "$PORT_FILE") - # Verify port is still alive - if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then - echo "Viz server already running at http://localhost:$port" - exit 0 - fi - # Stale session — clean up - echo "Cleaning up stale session..." - tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true - rm -f "$PORT_FILE" - else - tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +# Check if THIS project already has a running server +if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server already running at http://localhost:$port" + exit 0 fi + # Stale port file for this project + rm -f "$PORT_FILE" fi -# Clean up stale port file if tmux session is gone -if [[ -f "$PORT_FILE" ]] && ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then - rm -f "$PORT_FILE" +# If a tmux session exists, it may serve another project — kill it to reuse the session name +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + echo "Stopping existing viz session (may be from another project)..." + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true fi # Create venv if it doesn't exist diff --git a/viz/scripts/viz-status.sh b/viz/scripts/viz-status.sh index de8db163..7720effd 100755 --- a/viz/scripts/viz-status.sh +++ b/viz/scripts/viz-status.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Check the status of the Humanize Viz dashboard server. # Usage: viz-status.sh [--project ] +# +# Only checks/cleans up state for the specified project. +# Will NOT kill tmux sessions that may belong to other projects. set -euo pipefail @@ -11,23 +14,25 @@ HUMANIZE_DIR="$PROJECT_DIR/.humanize" PORT_FILE="$HUMANIZE_DIR/viz.port" TMUX_SESSION="humanize-viz" -if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then - if [[ -f "$PORT_FILE" ]]; then - port=$(cat "$PORT_FILE") - if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then - echo "Viz server running at http://localhost:$port" - exit 0 - fi +# If this project has a port file, check if the server is healthy +if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server running at http://localhost:$port" + exit 0 fi - # tmux session exists but server not responding — stale - echo "Viz server is not running (stale session detected, cleaning up)." - tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + # Port file exists but server not responding — stale for THIS project + echo "Viz server is not running (stale port file, cleaning up)." rm -f "$PORT_FILE" - exit 1 -else - if [[ -f "$PORT_FILE" ]]; then - rm -f "$PORT_FILE" + # Only kill tmux if we can confirm it's ours (check if tmux session exists) + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + # Verify the tmux session is serving this project by checking port + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true fi - echo "Viz server is not running." exit 1 fi + +# No port file for this project — server is not running here +# Do NOT kill any tmux session, it may serve another project +echo "Viz server is not running." +exit 1 diff --git a/viz/server/app.py b/viz/server/app.py index d5212f90..ebff1035 100644 --- a/viz/server/app.py +++ b/viz/server/app.py @@ -10,7 +10,7 @@ import argparse import subprocess import threading -from flask import Flask, jsonify, send_from_directory, abort +from flask import Flask, jsonify, request, send_from_directory, abort from flask_sock import Sock # Add server directory to path @@ -118,6 +118,121 @@ def health(): return jsonify({'status': 'ok'}) +# --- Project Management --- + +_PROJECTS_FILE = os.path.expanduser('~/.humanize/viz-projects.json') + + +def _load_projects(): + """Load saved project list.""" + if os.path.isfile(_PROJECTS_FILE): + try: + with open(_PROJECTS_FILE, 'r') as f: + return json.loads(f.read()) + except (json.JSONDecodeError, OSError): + pass + return [] + + +def _save_projects(projects): + os.makedirs(os.path.dirname(_PROJECTS_FILE), exist_ok=True) + with open(_PROJECTS_FILE, 'w') as f: + f.write(json.dumps(projects, indent=2)) + + +def _ensure_current_project(): + """Make sure the current PROJECT_DIR is in the saved list.""" + projects = _load_projects() + if PROJECT_DIR not in projects: + projects.insert(0, PROJECT_DIR) + _save_projects(projects) + + +@app.route('/api/projects') +def api_projects(): + _ensure_current_project() + projects = _load_projects() + result = [] + for p in projects: + rlcr_dir = os.path.join(p, '.humanize', 'rlcr') + session_count = 0 + if os.path.isdir(rlcr_dir): + session_count = len([d for d in os.listdir(rlcr_dir) if os.path.isdir(os.path.join(rlcr_dir, d))]) + result.append({ + 'path': p, + 'name': os.path.basename(p), + 'sessions': session_count, + 'active': p == PROJECT_DIR, + }) + return jsonify(result) + + +@app.route('/api/projects/switch', methods=['POST']) +def api_switch_project(): + global PROJECT_DIR, _watcher + data = json.loads(request.data) if request.data else {} + new_path = data.get('path', '') + + if not new_path or not os.path.isdir(new_path): + return jsonify({'error': 'Invalid project path'}), 400 + + rlcr_dir = os.path.join(new_path, '.humanize', 'rlcr') + if not os.path.isdir(os.path.join(new_path, '.humanize')): + return jsonify({'error': 'No .humanize/ directory in this project'}), 400 + + # Stop old watcher + if _watcher: + _watcher.stop() + + # Switch + PROJECT_DIR = os.path.abspath(new_path) + _invalidate_cache() + + # Restart watcher + _watcher = SessionWatcher(PROJECT_DIR, broadcast_message) + _watcher.start() + + # Save to project list + _ensure_current_project() + + return jsonify({'status': 'switched', 'path': PROJECT_DIR}) + + +@app.route('/api/projects/add', methods=['POST']) +def api_add_project(): + data = json.loads(request.data) if request.data else {} + new_path = data.get('path', '') + + if not new_path: + return jsonify({'error': 'Path required'}), 400 + + new_path = os.path.abspath(os.path.expanduser(new_path)) + if not os.path.isdir(new_path): + return jsonify({'error': 'Directory does not exist'}), 400 + + if not os.path.isdir(os.path.join(new_path, '.humanize')): + return jsonify({'error': 'No .humanize/ directory found'}), 400 + + projects = _load_projects() + if new_path not in projects: + projects.append(new_path) + _save_projects(projects) + + return jsonify({'status': 'added', 'path': new_path}) + + +@app.route('/api/projects/remove', methods=['POST']) +def api_remove_project(): + data = json.loads(request.data) if request.data else {} + path = data.get('path', '') + + projects = _load_projects() + projects = [p for p in projects if p != path] + _save_projects(projects) + + return jsonify({'status': 'removed'}) + + # --- REST API --- @app.route('/api/sessions') @@ -196,10 +311,18 @@ def api_generate_report(session_id): with open(report_path, 'r', encoding='utf-8') as f: return jsonify({'status': 'exists', 'content': f.read()}) - # Collect round summaries and review results - summaries = [] + # Collect round summaries and review results (sorted numerically by round number) import glob as _glob - for sf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))): + import re as _re_local + + def _sort_round_files(files): + def _round_num(path): + m = _re_local.search(r'round-(\d+)-', os.path.basename(path)) + return int(m.group(1)) if m else 0 + return sorted(files, key=_round_num) + + summaries = [] + for sf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))): try: with open(sf, 'r', encoding='utf-8') as f: summaries.append(f'--- {os.path.basename(sf)} ---\n{f.read()}') @@ -207,7 +330,7 @@ def api_generate_report(session_id): pass reviews = [] - for rf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))): + for rf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))): try: with open(rf, 'r', encoding='utf-8') as f: reviews.append(f'--- {os.path.basename(rf)} ---\n{f.read()}') diff --git a/viz/static/css/layout.css b/viz/static/css/layout.css index 6cf74fd6..c0041213 100644 --- a/viz/static/css/layout.css +++ b/viz/static/css/layout.css @@ -121,6 +121,52 @@ background: var(--border-0); } +/* ─── Project Switcher Bar ─── */ +.project-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); +} + +.project-current { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.project-current-label { + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-3); + flex-shrink: 0; +} + +.project-current-path { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.95rem; + color: var(--text-0); +} + +.project-current-full { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + /* ─── Session Cards ─── */ .cards-grid { display: grid; @@ -266,6 +312,10 @@ box-shadow var(--duration-base) var(--ease-out); overflow: hidden; z-index: 1; + height: 68px; + display: flex; + flex-direction: column; + justify-content: center; } .pl-node:hover { border-color: var(--border-2); diff --git a/viz/static/index.html b/viz/static/index.html index a074a376..0b54ec53 100644 --- a/viz/static/index.html +++ b/viz/static/index.html @@ -59,10 +59,10 @@
- - - - - + + + + + diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js index f410841a..9d5eb43c 100644 --- a/viz/static/js/actions.js +++ b/viz/static/js/actions.js @@ -255,6 +255,58 @@ async function sidebarSendIssue(sessionId) { } } +// ─── Project Switching ─── +async function switchProject(path) { + const res = await fetch('/api/projects/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) + if (res.ok) { + navigate('#/') + window.renderCurrentRoute() + } else { + const data = await res.json() + alert(data.error || 'Failed to switch project') + } +} + +function addProjectPrompt() { + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

Add Project

+

+ Enter the absolute path to a project directory that has a .humanize/ folder. +

+ + ` + document.getElementById('modal-overlay').classList.add('visible') + setTimeout(() => document.getElementById('add-project-input')?.focus(), 200) +} + +async function addProject() { + const input = document.getElementById('add-project-input') + const path = input?.value?.trim() + if (!path) return + + const res = await fetch('/api/projects/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) + const data = await res.json() + closeModal() + if (res.ok) { + window.renderCurrentRoute() + } else { + alert(data.error || 'Failed to add project') + } +} + // ─── Plan Viewer ─── async function showPlanViewer(sessionId) { const res = await fetch(`/api/sessions/${sessionId}/plan`) diff --git a/viz/static/js/app.js b/viz/static/js/app.js index 2bc5eb2c..213e3394 100644 --- a/viz/static/js/app.js +++ b/viz/static/js/app.js @@ -2,7 +2,7 @@ let ws = null, wsRetryDelay = 1000 const WS_MAX_RETRY = 30000 -let _sortCol = 'session_id', _sortAsc = true +let _sortCol = 'session_id', _sortAsc = false // ─── WebSocket ─── function connectWebSocket() { @@ -148,10 +148,42 @@ function _esc(str) { // ─── Home ─── async function renderHome() { const main = document.getElementById('main-content') - const sessions = await api('/api/sessions') + + // Load projects and sessions in parallel + const [projects, sessions] = await Promise.all([ + api('/api/projects').catch(() => []), + api('/api/sessions').catch(() => []), + ]) + + // Project switcher + const currentProject = (projects || []).find(p => p.active) || {} + const otherProjects = (projects || []).filter(p => !p.active) + const projectSwitcher = ` +
+
+ Project + ${_esc(currentProject.name || '—')} + ${_esc(currentProject.path || '')} +
+
+ ${otherProjects.length > 0 ? ` + ` : ''} + +
+
` if (!sessions || sessions.length === 0) { - main.innerHTML = `
${t('home.empty')}
${t('home.empty.hint')}
` + main.innerHTML = `
${projectSwitcher}
${t('home.empty')}
${t('home.empty.hint')}
` return } @@ -166,7 +198,7 @@ async function renderHome() { html += `
${finished.map(sessionCard).join('')}
` } - main.innerHTML = `
${html}
` + main.innerHTML = `
${projectSwitcher}${html}
` } function sessionCard(s) { @@ -382,18 +414,45 @@ async function renderAnalytics() { const o = data.overview - // Build per-session round verdict timeline - let timelineHtml = '' + main.innerHTML = ` +
+

${t('analytics.title')}

+
+
${o.total_sessions}
${t('analytics.total')}
+
${o.average_rounds}
${t('analytics.avg_rounds')}
+
${o.completion_rate}%
${t('analytics.completion')}
+
${o.total_bitlessons}
${t('analytics.bitlessons')}
+
+ +
+ +

${t('analytics.comparison')}

+
+
` + + buildCmpTable(data.session_stats) + + // Load timeline asynchronously (needs full session data, can be slow) if (data.session_stats && data.session_stats.length > 0) { - // Fetch full session data for verdict timeline + loadTimeline(data.session_stats) + } +} + +async function loadTimeline(sessionStats) { + const root = document.getElementById('timeline-root') + if (!root) return + + try { const sessions = await Promise.all( - data.session_stats.map(s => api(`/api/sessions/${s.session_id}`)) + sessionStats.map(s => api(`/api/sessions/${s.session_id}`).catch(() => null)) ) - timelineHtml = sessions.filter(Boolean).map(s => { + const valid = sessions.filter(Boolean) + if (valid.length === 0) return + + const rows = valid.map(s => { const dots = (s.rounds || []).map(r => { const v = r.verdict || 'unknown' - const title = `R${r.number}: ${v}` - return `` + return `` }).join('') return `
${s.id.slice(5, 16).replace('_', ' ')} @@ -401,22 +460,11 @@ async function renderAnalytics() { ${t('status.' + s.status)}
` }).join('') - } - - main.innerHTML = ` -
-

${t('analytics.title')}

-
-
${o.total_sessions}
${t('analytics.total')}
-
${o.average_rounds}
${t('analytics.avg_rounds')}
-
${o.completion_rate}%
${t('analytics.completion')}
-
${o.total_bitlessons}
${t('analytics.bitlessons')}
-
- ${timelineHtml ? ` + root.innerHTML = `
-
${timelineHtml}
+
${rows}
advanced stalled @@ -424,22 +472,10 @@ async function renderAnalytics() { complete unknown
-
` : ''} - -
-

${t('analytics.rounds_trend')}

-

${t('analytics.duration')}

-

${t('analytics.verdicts')}

-

${t('analytics.p_issues')}

-

${t('analytics.first_complete')}

-

${t('analytics.bl_growth')}

-
-

${t('analytics.comparison')}

-
-
` - - if (typeof buildCharts === 'function') buildCharts(data) - buildCmpTable(data.session_stats) +
` + } catch (e) { + console.error('[analytics] timeline failed:', e) + } } function buildCmpTable(stats) { diff --git a/viz/static/js/charts.js b/viz/static/js/charts.js index a27f8453..f536c95d 100644 --- a/viz/static/js/charts.js +++ b/viz/static/js/charts.js @@ -1,4 +1,5 @@ -/* Chart.js analytics — theme-aware, handles sparse data gracefully */ +/* Chart.js analytics v3 */ +console.log('[charts] v3 loaded') const _charts = {} @@ -73,6 +74,8 @@ function buildCharts(data) { const stats = data.session_stats || [] const labels = stats.map(s => s.session_id.slice(5, 16).replace('_', ' ')) + console.log('[charts] buildCharts called, stats:', stats.length, 'el c-rounds:', !!document.getElementById('c-rounds')) + // 1. Rounds per session if (stats.length > 0) { const ch = _makeChart('c-rounds', { diff --git a/viz/static/js/pipeline.js b/viz/static/js/pipeline.js index 6cec5b07..f5251202 100644 --- a/viz/static/js/pipeline.js +++ b/viz/static/js/pipeline.js @@ -3,7 +3,7 @@ const PL = { COLS: 4, NODE_W: 230, - NODE_H: 72, + NODE_H: 68, GAP_X: 52, GAP_Y: 48, TURN_H: 56,