'use strict';

// ================================================================
// STATE
// ================================================================

let allTasks = [];
let allResults = [];
let allProjects = [];
let allBatches = [];
let statsData = {};
let selectedTaskId = null;
let selectedTask = null;
let selectedResult = null;
let logLines = [];
let logSearchTerm = '';
let expandedLogLines = new Set();
let autoScroll = true;
let logViewMode = 'flow'; // 'flow' or 'raw'
let parsedLogEvents = []; // parsed structured events
let expandedEvents = new Set(); // indices of expanded event cards
let sidebarCollapsed = false;
let sidebarWidth = 280;
let sidebarResizeState = null;
let projectFilter = null; // for filtering task list by project
let taskSearch = '';
let statusFilter = 'all';
let refreshMeta = {
  loading: true,
  connected: false,
  lastUpdated: null,
  error: '',
};
let transientBanner = null;
let sectionState = {
  running: true,   // always expanded
  pending: true,   // expanded by default
  completed: false, // collapsed by default
  failed: false,    // collapsed by default
};

let usageData = null;
let utilizationTimer = null;
let utilHistory = [];
let allSpokes = [];
let networkAnimTarget = null; // spoke id for dobby animation
let networkAnimDir = 'idle'; // 'to-spoke', 'to-hub', 'idle'

// Timers
let taskListTimer = null;
let logTimer = null;
let detailStatusTimer = null;
let detailStatusData = null;

const SIDEBAR_WIDTH_STORAGE_KEY = 'dobby.dashboard.sidebarWidth';
const THEME_STORAGE_KEY = 'dobby.dashboard.theme';
const PROJECT_GROUP_STORAGE_KEY = 'dobby.dashboard.projectGroups';
const PROJECT_GROUP_HISTORY_STORAGE_KEY = 'dobby.dashboard.projectGroupHistory';
const USAGE_SECTION_STORAGE_KEY = 'dobby.dashboard.usageExpanded';
const SIDEBAR_MIN_WIDTH = 180;
const SIDEBAR_MAX_WIDTH = 500;
const SIDEBAR_RECENT_HISTORY_LIMIT = 6;
let projectGroupState = {};
let projectGroupHistoryState = {};
let usageExpanded = true;

const TABLER_ICON_BODIES = {
  search: '<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" />',
  'brand-github': '<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5" />',
  home: '<path d="M5 12l-2 0l9 -9l9 9l-2 0" /><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" /><path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />',
  refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" /><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />',
  moon: '<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008" />',
  'sun-high': '<path d="M14.828 14.828a4 4 0 1 0 -5.656 -5.656a4 4 0 0 0 5.656 5.656" /><path d="M6.343 17.657l-1.414 1.414" /><path d="M6.343 6.343l-1.414 -1.414" /><path d="M17.657 6.343l1.414 -1.414" /><path d="M17.657 17.657l1.414 1.414" /><path d="M4 12h-2" /><path d="M12 4v-2" /><path d="M20 12h2" /><path d="M12 20v2" />',
  'menu-2': '<path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l16 0" />',
  'chevron-left': '<path d="M15 6l-6 6l6 6" />',
  'chevron-right': '<path d="M9 6l6 6l-6 6" />',
  folder: '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />',
  code: '<path d="M7 8l-4 4l4 4" /><path d="M17 8l4 4l-4 4" /><path d="M14 4l-4 16" />',
  plug: '<path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054" /><path d="M4 20l3.5 -3.5" /><path d="M15 4l-3.5 3.5" /><path d="M20 9l-3.5 3.5" />',
  cloud: '<path d="M6.657 18c-2.572 0 -4.657 -2.007 -4.657 -4.483c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 1.927 -1.551 3.487 -3.465 3.487h-11.878" />',
  cpu: '<path d="M5 6a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -12" /><path d="M9 9h6v6h-6l0 -6" /><path d="M3 10h2" /><path d="M3 14h2" /><path d="M10 3v2" /><path d="M14 3v2" /><path d="M21 10h-2" /><path d="M21 14h-2" /><path d="M14 21v-2" /><path d="M10 21v-2" />',
  package: '<path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" /><path d="M12 12l8 -4.5" /><path d="M12 12l0 9" /><path d="M12 12l-8 -4.5" /><path d="M16 5.25l-8 4.5" />',
  notebook: '<path d="M6 4h11a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-11a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1m3 0v18" /><path d="M13 8l2 0" /><path d="M13 12l2 0" />',
  'message-circle': '<path d="M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1" />',
  'device-mobile': '<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14" /><path d="M11 4h2" /><path d="M12 17v.01" />',
  palette: '<path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />',
  spiral: '<path d="M10 12.057a1.9 1.9 0 0 0 .614 .743c1.06 .713 2.472 .112 3.043 -.919c.839 -1.513 -.022 -3.368 -1.525 -4.08c-2 -.95 -4.371 .154 -5.24 2.086c-1.095 2.432 .29 5.248 2.71 6.246c2.931 1.208 6.283 -.418 7.438 -3.255c1.36 -3.343 -.557 -7.134 -3.896 -8.41c-3.855 -1.474 -8.2 .68 -9.636 4.422c-1.63 4.253 .823 9.024 5.082 10.576c4.778 1.74 10.118 -.941 11.833 -5.59a9.354 9.354 0 0 0 .577 -2.813" />',
  'mood-smile': '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M9 10l.01 0" /><path d="M15 10l.01 0" /><path d="M9.5 15a3.5 3.5 0 0 0 5 0" />',
  eye: '<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />',
  quote: '<path d="M10 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5" /><path d="M19 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5" />',
  wand: '<path d="M6 21l15 -15l-3 -3l-15 15l3 3" /><path d="M15 6l3 3" /><path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" /><path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />',
};

// ================================================================
// UTILITIES
// ================================================================

function escHtml(s) {
  if (!s) return '';
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function iconSvg(name, className = 'tabler-icon') {
  const body = TABLER_ICON_BODIES[name] || TABLER_ICON_BODIES.folder;
  return `<svg xmlns="http://www.w3.org/2000/svg" class="${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
}

function renderIconInto(id, name, className = 'tabler-icon') {
  const el = document.getElementById(id);
  if (!el) return;
  el.innerHTML = iconSvg(name, className);
}

function loadStoredMap(key) {
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return {};
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch (err) {
    return {};
  }
}

function saveStoredMap(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch (err) {}
}

function loadStoredBoolean(key, defaultValue) {
  try {
    const raw = localStorage.getItem(key);
    if (raw === null) return !!defaultValue;
    return raw === 'true';
  } catch (err) {
    return !!defaultValue;
  }
}

function saveStoredBoolean(key, value) {
  try {
    localStorage.setItem(key, value ? 'true' : 'false');
  } catch (err) {}
}

function shortTitle(desc) {
  if (!desc) return '';
  let text = desc.trim();

  // Remove leading markdown headers (# Title)
  text = text.replace(/^#+\s*/, '');
  // Remove leading numbering: "(1)", "1.", "1)"
  text = text.replace(/^\(?[0-9]+[.)]\s*/, '');
  // Remove common structural prefixes ending with colon if followed by more text
  // e.g. "Frontend polish: Add ANSI support" → "Add ANSI support"
  // But keep "Fix the login bug" (no colon structure)
  const colonMatch = text.match(/^[A-Za-z][A-Za-z\s]{0,30}:\s*(.+)/);
  if (colonMatch && colonMatch[1].length > 10) {
    text = colonMatch[1];
  }

  // Take first line only
  text = text.split('\n')[0].trim();

  // If still too long, take up to the first period or comma that's after 15 chars
  if (text.length > 60) {
    const sentenceEnd = text.match(/^(.{15,60}?)[.,;!?]\s/);
    if (sentenceEnd) text = sentenceEnd[1];
  }

  // Final truncate
  if (text.length > 60) text = text.substring(0, 57) + '...';
  return text;
}

function stripAnsi(s) {
  const ansiPattern = new RegExp(String.fromCharCode(27) + '\[[0-9;]*m', 'g');
  return (s || '').replace(ansiPattern, '');
}

function applyAnsiCodes(state, rawCodes) {
  const codes = (rawCodes === '' ? ['0'] : rawCodes.split(';'))
    .map(code => code === '' ? 0 : Number(code))
    .filter(code => Number.isFinite(code));

  if (!codes.length) codes.push(0);

  for (const code of codes) {
    if (code === 0) {
      state.bold = false;
      state.fg = null;
    } else if (code === 1) {
      state.bold = true;
    } else if (code === 22) {
      state.bold = false;
    } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
      state.fg = code;
    } else if (code === 39) {
      state.fg = null;
    }
  }
}

function ansiToHtml(text) {
  const source = text || '';
  const ansiPattern = new RegExp(String.fromCharCode(27) + '\[([0-9;]*)m', 'g');
  const state = { fg: null, bold: false };
  let html = '';
  let lastIndex = 0;

  function wrapSegment(segment) {
    if (!segment) return '';
    const classes = [];
    if (state.bold) classes.push('ansi-bold');
    if (state.fg !== null) classes.push(`ansi-fg-${state.fg}`);
    const content = escHtml(segment);
    return classes.length ? `<span class="${classes.join(' ')}">${content}</span>` : content;
  }

  source.replace(ansiPattern, (match, rawCodes, offset) => {
    html += wrapSegment(source.slice(lastIndex, offset));
    applyAnsiCodes(state, rawCodes);
    lastIndex = offset + match.length;
    return match;
  });

  html += wrapSegment(source.slice(lastIndex));
  return html;
}

function tryParseJsonLogLine(line) {
  const trimmed = stripAnsi(line).trim();
  if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
  try {
    return JSON.parse(trimmed);
  } catch (e) {
    return null;
  }
}

function jsonSyntaxHtml(jsonText) {
  return jsonText.replace(
    /("(?:\u[a-fA-F0-9]{4}|\[^u]|[^\"])*"(?:\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g,
    match => {
      let cls = 'json-number';
      if (match.startsWith('"')) {
        cls = match.endsWith(':') ? 'json-key' : 'json-string';
      } else if (match === 'true' || match === 'false') {
        cls = 'json-boolean';
      } else if (match === 'null') {
        cls = 'json-null';
      }
      return `<span class="${cls}">${escHtml(match)}</span>`;
    }
  );
}

function renderFormattedLogContent(text, key, parsedJson = undefined) {
  const source = text == null ? '' : String(text);
  const resolvedJson = parsedJson === undefined ? tryParseJsonLogLine(source) : parsedJson;
  const prettyJson = resolvedJson !== null ? JSON.stringify(resolvedJson, null, 2) : null;
  const isLong = (prettyJson ? prettyJson.length : stripAnsi(source).length) > 200;
  const isExpanded = expandedLogLines.has(key);
  const contentClasses = ['log-content'];

  if (resolvedJson !== null) contentClasses.push('is-json');
  if (isLong && !isExpanded) contentClasses.push('collapsed');

  const rendered = prettyJson !== null
    ? `<pre class="log-json">${jsonSyntaxHtml(prettyJson)}</pre>`
    : `<div class="log-plain">${ansiToHtml(source) || '&nbsp;'}</div>`;

  const toggle = isLong
    ? `<button class="log-expand-btn" onclick="toggleLogLine('${key}')">${isExpanded ? 'Show less' : 'Show more'}</button>`
    : '';

  return `<div class="${contentClasses.join(' ')}">${rendered}</div>${toggle}`;
}

function renderLogTextBlock(text, keyPrefix) {
  const source = text == null ? '' : String(text);
  const parsedJson = tryParseJsonLogLine(source);
  if (parsedJson !== null) {
    return `<div class="log-rich-block">${renderFormattedLogContent(source, `${keyPrefix}-json`, parsedJson)}</div>`;
  }

  const lines = source.split('\n');
  return `<div class="log-rich-block">${lines.map((line, index) => {
    return `<div class="log-rich-line">${renderFormattedLogContent(line, `${keyPrefix}-${index}`)}</div>`;
  }).join('')}</div>`;
}

function renderLogLineContent(line, index) {
  return renderFormattedLogContent(line, `raw-${index}`);
}

function toggleLogLine(index) {
  if (expandedLogLines.has(index)) {
    expandedLogLines.delete(index);
  } else {
    expandedLogLines.add(index);
  }
  renderLogs();
}

function shortId(id) {
  return id ? id.slice(0, 12) : '--';
}

function isMobileLayout() {
  return window.innerWidth <= 900;
}

function elapsed(startedAt) {
  if (!startedAt) return '--';
  const diff = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
  if (diff < 0) return '--';
  if (diff < 60) return diff + 's';
  if (diff < 3600) return Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
  return Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
}

function relativeTime(iso) {
  if (!iso) return '';
  const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
  if (diff < 0) return 'just now';
  if (diff < 60) return diff + 's ago';
  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
  return Math.floor(diff / 86400) + 'd ago';
}

function fmtDate(iso) {
  if (!iso) return '--';
  const d = new Date(iso);
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

function fmtCost(val) {
  if (val == null || val === undefined) return '--';
  return '$' + Number(val).toFixed(4);
}

function fmtTokens(val) {
  if (val == null || val === undefined || val === 0) return '--';
  if (val >= 1000000) return (val / 1000000).toFixed(1) + 'M';
  if (val >= 1000) return (val / 1000).toFixed(1) + 'K';
  return String(val);
}

function fmtDurationShort(seconds) {
  if (!seconds || seconds <= 0) return '--';
  if (seconds < 60) return Math.round(seconds) + 's';
  if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + Math.round(seconds % 60) + 's';
  return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}

function durationBetween(iso1, iso2) {
  if (!iso1 || !iso2) return null;
  const ms = new Date(iso2).getTime() - new Date(iso1).getTime();
  if (ms < 0) return null;
  const s = Math.floor(ms / 1000);
  if (s < 60) return s + 's';
  if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
  return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
}

function statusDotClass(status) {
  return 'dot-' + (status || 'pending');
}

function runningDurationSeconds(task) {
  const startIso = task.started_at || task.created_at;
  if (!startIso) return 0;
  const diff = (Date.now() - new Date(startIso).getTime()) / 1000;
  return diff > 0 ? diff : 0;
}

function resultForTask(taskId) {
  return allResults.find(r => r.task_id === taskId) || null;
}

function queuedTasks(tasks) {
  return tasks.filter(t => ['pending', 'active', 'drafting'].includes(t.status));
}

function isRecurringTask(task) {
  return !!(task.schedule && ['cron', 'interval'].includes(task.schedule.type));
}

function isBlockedTask(task) {
  return !!(task.schedule && task.schedule.type === 'after' && task.status === 'active');
}

function taskScheduleLabel(task) {
  const schedule = task.schedule || {};
  if (schedule.type === 'interval') return schedule.every ? 'every ' + schedule.every : 'interval';
  if (schedule.type === 'cron') return 'cron';
  if (schedule.type === 'after') return schedule.depends_on ? 'after ' + shortId(schedule.depends_on) : 'after';
  if (task.mode === 'planned' || task.status === 'drafting') return 'planned';
  return 'manual';
}

function taskText(task) {
  const result = resultForTask(task.id);
  return [
    task.id,
    task.project,
    task.description,
    task.status,
    task.priority,
    result && result.summary,
  ].filter(Boolean).join(' ').toLowerCase();
}

function matchesTaskSearch(task) {
  if (!taskSearch) return true;
  return taskText(task).includes(taskSearch);
}

function matchesStatusFilter(task) {
  if (statusFilter === 'all') return true;
  if (statusFilter === 'pending') {
    return ['pending', 'active', 'drafting'].includes(task.status);
  }
  if (statusFilter === 'failed') {
    return ['failed', 'cancelled'].includes(task.status);
  }
  return task.status === statusFilter;
}

function getVisibleTasks(tasks) {
  return tasks.filter(task => {
    if (projectFilter && task.project !== projectFilter) return false;
    if (!matchesTaskSearch(task)) return false;
    if (!matchesStatusFilter(task)) return false;
    return true;
  });
}

function completionTimeForTask(task) {
  const result = resultForTask(task.id);
  return (result && result.completed_at) || task.created_at || null;
}

function durationSecondsForTask(task) {
  const endIso = completionTimeForTask(task);
  const result = resultForTask(task.id);
  const startIso = (result && result.started_at) || task.started_at || task.created_at;
  if (!startIso || !endIso) return null;
  const ms = new Date(endIso).getTime() - new Date(startIso).getTime();
  if (ms <= 0) return null;
  return ms / 1000;
}

function isToday(iso) {
  if (!iso) return false;
  const d = new Date(iso);
  const now = new Date();
  return d.getFullYear() === now.getFullYear() &&
         d.getMonth() === now.getMonth() &&
         d.getDate() === now.getDate();
}

async function fetchJson(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(response.status + ' ' + response.statusText);
  }
  return response.json();
}

function setTransientBanner(message, tone) {
  transientBanner = {
    message: message,
    tone: tone || 'warn',
    expiresAt: Date.now() + 5000,
  };
  renderStatusBanner();
}

function renderMainHeader() {
  const refreshEl = document.getElementById('refresh-status');
  const filterEl = document.getElementById('filter-status');
  const clearBtn = document.getElementById('clear-filter-btn');

  if (refreshMeta.loading) {
    refreshEl.className = 'status-pill info';
    refreshEl.textContent = 'Loading queue';
  } else if (refreshMeta.error) {
    refreshEl.className = 'status-pill error';
    refreshEl.textContent = 'Refresh failed';
  } else if (refreshMeta.connected && refreshMeta.lastUpdated) {
    refreshEl.className = 'status-pill ok';
    refreshEl.textContent = 'Updated ' + relativeTime(refreshMeta.lastUpdated);
  } else {
    refreshEl.className = 'status-pill warn';
    refreshEl.textContent = 'Waiting for daemon';
  }

  if (projectFilter) {
    filterEl.className = 'status-pill info';
    filterEl.textContent = 'Filter ' + projectFilter;
    clearBtn.classList.remove('hidden');
  } else {
    filterEl.className = 'status-pill info';
    filterEl.textContent = 'All projects';
    clearBtn.classList.add('hidden');
  }

  renderThemeToggle();
}

function renderStatusBanner() {
  const banner = document.getElementById('status-banner');

  if (transientBanner && transientBanner.expiresAt <= Date.now()) {
    transientBanner = null;
  }

  if (transientBanner) {
    banner.dataset.tone = transientBanner.tone;
    banner.classList.add('visible');
    banner.textContent = transientBanner.message;
    return;
  }

  if (refreshMeta.error) {
    banner.dataset.tone = 'error';
    banner.classList.add('visible');
    banner.textContent = 'Could not fully refresh dashboard data. Showing the last good snapshot where available. Last error: ' + refreshMeta.error;
    return;
  }

  if (projectFilter) {
    banner.dataset.tone = 'warn';
    banner.classList.add('visible');
    banner.textContent = 'Project filter is active for ' + projectFilter + '. Sidebar groups, live jobs, activity, and stats are narrowed to that project.';
    return;
  }

  if (taskSearch || statusFilter !== 'all') {
    banner.dataset.tone = 'warn';
    banner.classList.add('visible');
    const searchText = taskSearch ? (' Search: "' + taskSearch + '".') : '';
    const statusText = statusFilter !== 'all' ? (' Status: ' + statusFilter + '.') : '';
    banner.textContent = 'Dashboard filters are active.' + searchText + statusText;
    return;
  }

  banner.classList.remove('visible');
  banner.textContent = '';
  delete banner.dataset.tone;
}

function currentTheme() {
  return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
}

function projectSlug(name) {
  return String(name || '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '') || 'default';
}

function projectDisplayName(name) {
  const map = {
    spare: 'SPARE',
    adpo: 'ADPO',
    dobby: 'Dobby',
    vibelens: 'VibeLens',
    egao: 'Meta-Agent',
    'verbalized-sampling': 'Verbalized Sampling',
  };
  const slug = projectSlug(name);
  return map[slug] || name;
}

function projectGitUrl(name) {
  const proj = allProjects.find(p => p.name === name);
  if (proj && proj.git_remote) {
    // Clean up .git suffix for display URL
    return proj.git_remote.replace(/\.git$/, '');
  }
  return null;
}

function projectIconMeta(name) {
  const source = String(name || '').trim().toLowerCase();
  const slug = projectSlug(name);
  if (!source) return { icon: 'folder', badgeClass: 'project-icon-generic' };

  const exactMap = {
    spare: { icon: 'spiral', badgeClass: 'project-icon-spare' },
    egao: { icon: 'mood-smile', badgeClass: 'project-icon-egao' },
    vibelens: { icon: 'eye', badgeClass: 'project-icon-vibelens' },
    'verbalized-sampling': { icon: 'quote', badgeClass: 'project-icon-verbalized-sampling' },
    dobby: { icon: 'wand', badgeClass: 'project-icon-dobby' },
  };

  if (exactMap[slug]) return exactMap[slug];

  const keywordMap = [
    { match: ['frontend', 'dashboard', 'ui', 'web', 'site'], icon: 'code', badgeClass: 'project-icon-generic' },
    { match: ['api', 'backend', 'server'], icon: 'plug', badgeClass: 'project-icon-generic' },
    { match: ['infra', 'ops', 'deploy', 'vultr', 'cloud'], icon: 'cloud', badgeClass: 'project-icon-generic' },
    { match: ['gpu', 'train', 'training', 'model', 'eval'], icon: 'cpu', badgeClass: 'project-icon-generic' },
    { match: ['data', 'batch', 'pipeline'], icon: 'package', badgeClass: 'project-icon-generic' },
    { match: ['docs', 'doc', 'guide', 'notes'], icon: 'notebook', badgeClass: 'project-icon-generic' },
    { match: ['slack', 'chat', 'bot'], icon: 'message-circle', badgeClass: 'project-icon-generic' },
    { match: ['mobile', 'ios', 'android'], icon: 'device-mobile', badgeClass: 'project-icon-generic' },
    { match: ['design', 'brand', 'creative'], icon: 'palette', badgeClass: 'project-icon-generic' },
  ];

  for (const entry of keywordMap) {
    if (entry.match.some(part => source.includes(part))) {
      return { icon: entry.icon, badgeClass: entry.badgeClass };
    }
  }

  const fallbackIcons = ['folder', 'code', 'plug', 'cloud', 'cpu', 'package', 'notebook', 'message-circle', 'device-mobile', 'palette'];
  let hash = 0;
  for (let i = 0; i < source.length; i += 1) {
    hash = (hash * 31 + source.charCodeAt(i)) % fallbackIcons.length;
  }
  return { icon: fallbackIcons[hash], badgeClass: 'project-icon-generic' };
}

function renderStaticIcons() {
  renderIconInto('sidebar-search-icon', 'search', 'tabler-icon tabler-icon-search');
  renderIconInto('sidebar-github-icon', 'brand-github', 'tabler-icon tabler-icon-link');
  renderIconInto('sidebar-home-icon', 'home', 'tabler-icon tabler-icon-link');
  renderIconInto('sidebar-open-icon', 'menu-2', 'tabler-icon tabler-icon-button');
  renderIconInto('refresh-btn-glyph', 'refresh', 'tabler-icon tabler-icon-button');
  renderSidebarToggleIcon();
}

function renderSidebarToggleIcon() {
  const iconName = sidebarCollapsed && !isMobileLayout() ? 'chevron-right' : 'chevron-left';
  renderIconInto('toggle-icon', iconName, 'tabler-icon tabler-icon-button sidebar-toggle-icon');
}

function setTheme(theme, persist = true) {
  const nextTheme = theme === 'light' ? 'light' : 'dark';
  document.documentElement.dataset.theme = nextTheme;
  if (persist) {
    try {
      localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
    } catch (err) {}
  }
  renderThemeToggle();
}

function renderThemeToggle() {
  const btn = document.getElementById('theme-toggle-btn');
  if (!btn) return;
  const glyph = btn.querySelector('.icon-btn-glyph');

  if (currentTheme() === 'light') {
    if (glyph) glyph.innerHTML = iconSvg('moon', 'tabler-icon tabler-icon-button');
    btn.title = 'Switch to dark theme';
    btn.setAttribute('aria-label', 'Switch to dark theme');
  } else {
    if (glyph) glyph.innerHTML = iconSvg('sun-high', 'tabler-icon tabler-icon-button');
    btn.title = 'Switch to light theme';
    btn.setAttribute('aria-label', 'Switch to light theme');
  }
}

function restoreThemePreference() {
  let theme = 'dark';
  try {
    const stored = localStorage.getItem(THEME_STORAGE_KEY);
    if (stored === 'light' || stored === 'dark') {
      theme = stored;
    }
  } catch (err) {}
  setTheme(theme, false);
}

function toggleTheme() {
  setTheme(currentTheme() === 'light' ? 'dark' : 'light');
}

// ================================================================
// SIDEBAR TOGGLE
// ================================================================

function clampSidebarWidth(width) {
  return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width));
}

function setSidebarWidth(width, persist = true) {
  const nextWidth = clampSidebarWidth(width);
  sidebarWidth = nextWidth;
  document.documentElement.style.setProperty('--sidebar-w', nextWidth + 'px');

  if (!persist) return;

  try {
    localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(nextWidth));
  } catch (err) {}
}

function restoreSidebarWidth() {
  try {
    const storedWidth = Number(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY));
    if (Number.isFinite(storedWidth)) {
      setSidebarWidth(storedWidth, false);
      return;
    }
  } catch (err) {}

  setSidebarWidth(sidebarWidth, false);
}

function updateSidebarResizeHandle() {
  const handle = document.getElementById('sidebar-resize-handle');
  if (!handle) return;
  handle.classList.toggle('disabled', sidebarCollapsed || isMobileLayout());
}

function resizeSidebarFromDrag(event) {
  if (!sidebarResizeState) return;
  const deltaX = event.clientX - sidebarResizeState.startX;
  setSidebarWidth(sidebarResizeState.startWidth + deltaX, false);
}

function stopSidebarResize() {
  if (!sidebarResizeState) return;

  sidebarResizeState = null;
  document.body.classList.remove('sidebar-resizing');
  document.getElementById('sidebar').classList.remove('resizing');
  window.removeEventListener('mousemove', resizeSidebarFromDrag);
  window.removeEventListener('mouseup', stopSidebarResize);
  setSidebarWidth(sidebarWidth);
}

function startSidebarResize(event) {
  if (event.button !== 0 || sidebarCollapsed || isMobileLayout()) return;

  const sidebar = document.getElementById('sidebar');
  if (!sidebar) return;

  event.preventDefault();
  sidebarResizeState = {
    startX: event.clientX,
    startWidth: sidebar.getBoundingClientRect().width,
  };

  document.body.classList.add('sidebar-resizing');
  sidebar.classList.add('resizing');
  window.addEventListener('mousemove', resizeSidebarFromDrag);
  window.addEventListener('mouseup', stopSidebarResize);
}

function setupSidebarResize() {
  restoreSidebarWidth();

  const handle = document.getElementById('sidebar-resize-handle');
  if (!handle) return;

  handle.addEventListener('mousedown', startSidebarResize);
  updateSidebarResizeHandle();
}

function toggleSidebar() {
  if (isMobileLayout()) {
    document.body.classList.toggle('mobile-sidebar-open');
    return;
  }
  sidebarCollapsed = !sidebarCollapsed;
  document.getElementById('sidebar').classList.toggle('collapsed', sidebarCollapsed);
  renderSidebarToggleIcon();
  updateSidebarResizeHandle();
}

function closeSidebarOnMobile() {
  document.body.classList.remove('mobile-sidebar-open');
}

function syncSidebarForViewport() {
  if (isMobileLayout()) {
    stopSidebarResize();
  }

  if (isMobileLayout()) {
    sidebarCollapsed = false;
    document.getElementById('sidebar').classList.remove('collapsed');
  } else {
    closeSidebarOnMobile();
  }

  renderSidebarToggleIcon();
  updateSidebarResizeHandle();
}

async function manualRefresh() {
  await refreshAll();
  if (!refreshMeta.error) {
    setTransientBanner('Dashboard refreshed.', 'success');
  }
}

// ================================================================
// DOCS VIEW (Changelog / Wishlist)
// ================================================================

function simpleMarkdown(md) {
  // Minimal markdown → HTML (no external lib)
  let html = escHtml(md);
  // Headers
  html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
  html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
  html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
  // Bold
  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  // Italic
  html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
  // Code blocks
  html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
  // Inline code
  html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
  // Checkbox
  html = html.replace(/- \[x\] (.+)/g, '<div class="docs-check done">&#x2611; $1</div>');
  html = html.replace(/- \[ \] (.+)/g, '<div class="docs-check">&#x2610; $1</div>');
  // Lists
  html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
  html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
  // HR
  html = html.replace(/^---$/gm, '<hr>');
  // Links
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
  // Paragraphs (double newline)
  html = html.replace(/\n\n/g, '</p><p>');
  return '<p>' + html + '</p>';
}

async function showDocs(doc) {
  document.getElementById('overview').classList.remove('visible');
  document.getElementById('detail').classList.remove('visible');
  document.getElementById('docs-view').style.display = '';
  // Activate tab
  document.querySelectorAll('.docs-tab').forEach(t => t.classList.toggle('active', t.dataset.doc === doc));
  switchDoc(document.querySelector(`.docs-tab[data-doc="${doc}"]`));
}

async function switchDoc(el) {
  if (!el || !el.dataset || !el.dataset.doc) return;
  const doc = el.dataset.doc;
  document.querySelectorAll('.docs-tab').forEach(t => t.classList.toggle('active', t.dataset.doc === doc));
  const content = document.getElementById('docs-content');
  content.innerHTML = '<div class="docs-loading">Loading...</div>';
  try {
    if (doc === 'memory') {
      const memories = await fetchJson('/memories');
      if (!memories || memories.length === 0) {
        content.innerHTML = '<div class="docs-rendered"><h1>Memory</h1><p>No memories stored yet. Use <code>dobby_remember</code> to save memories.</p></div>';
        return;
      }
      const types = ['feedback', 'project', 'user', 'reference', 'general'];
      let md = '# Dobby Memory\n\n';
      for (const type of types) {
        const group = memories.filter(m => m.type === type);
        if (group.length === 0) continue;
        md += `## ${type.charAt(0).toUpperCase() + type.slice(1)}\n\n`;
        for (const m of group) {
          md += `### ${m.key}\n\n${m.value}\n\n*Updated: ${m.updated_at}*\n\n---\n\n`;
        }
      }
      content.innerHTML = `<div class="docs-rendered">${simpleMarkdown(md)}</div>`;
    } else {
      const data = await fetchJson(`/docs/${doc}`);
      content.innerHTML = `<div class="docs-rendered">${simpleMarkdown(data.content)}</div>`;
    }
  } catch (e) {
    content.innerHTML = '<div class="docs-loading">Failed to load.</div>';
  }
}

function showOverview() {
  document.getElementById('docs-view').style.display = 'none';
  document.getElementById('overview').classList.add('visible');
}

function clearProjectFilter() {
  projectFilter = null;
  renderOverview();
  renderTaskList(allTasks);
  renderMainHeader();
  renderStatusBanner();
}

function updateSearch(value) {
  taskSearch = (value || '').trim().toLowerCase();
  renderToolbar();
  renderTaskList(allTasks);
  renderOverview();
  renderStatusBanner();
}

function setStatusFilter(nextFilter) {
  statusFilter = nextFilter;
  renderToolbar();
  renderTaskList(allTasks);
  renderOverview();
  renderStatusBanner();
}

// ================================================================
// QUEUE BAR with Tooltips
// ================================================================

function renderQueueBar(stats) {
  const bar = document.getElementById('queue-bar');
  const counts = document.getElementById('queue-bar-counts');

  const p = (stats.pending || 0) + (stats.active || 0) + (stats.drafting || 0);
  const r = stats.running || 0;
  const c = stats.completed || 0;
  const f = (stats.failed || 0) + (stats.cancelled || 0);
  const total = p + r + c + f;

  if (total === 0) {
    bar.innerHTML = '';
    counts.innerHTML = '<span style="color:var(--text-dim)">no tasks</span>';
    return;
  }

  const pct = (v) => ((v / total) * 100).toFixed(1);

  function segHtml(count, cls, label) {
    if (count === 0) return '';
    const w = pct(count);
    const tipText = `${label}: ${count} task${count !== 1 ? 's' : ''} (${w}%)`;
    return `<div class="seg ${cls}" style="width:${w}%"
      onmouseenter="showQueueTip(event,'${tipText}')"
      onmousemove="moveQueueTip(event)"
      onmouseleave="hideQueueTip()"
    ></div>`;
  }

  bar.innerHTML =
    segHtml(r, 'seg-running', 'Running') +
    segHtml(p, 'seg-pending', 'Pending') +
    segHtml(c, 'seg-completed', 'Completed') +
    segHtml(f, 'seg-failed', 'Failed');

  counts.innerHTML =
    `<span><span class="qb-dot" style="background:var(--amber)"></span>${r}</span>` +
    `<span><span class="qb-dot" style="background:var(--blue)"></span>${p}</span>` +
    `<span><span class="qb-dot" style="background:var(--green)"></span>${c}</span>` +
    `<span><span class="qb-dot" style="background:var(--red)"></span>${f}</span>`;
}

function showQueueTip(e, text) {
  let tip = document.getElementById('queue-tip');
  if (!tip) { tip = document.createElement('div'); tip.id = 'queue-tip'; tip.className = 'seg-tooltip'; document.body.appendChild(tip); }
  tip.textContent = text;
  tip.style.display = 'block';
  tip.style.opacity = '1';
  tip.style.left = (e.clientX + 8) + 'px';
  tip.style.top = (e.clientY - 30) + 'px';
}
function moveQueueTip(e) {
  const tip = document.getElementById('queue-tip');
  if (tip) { tip.style.left = (e.clientX + 8) + 'px'; tip.style.top = (e.clientY - 30) + 'px'; }
}
function hideQueueTip() {
  const tip = document.getElementById('queue-tip');
  if (tip) { tip.style.opacity = '0'; tip.style.display = 'none'; }
}

// ================================================================
// TASK LIST — Project-First Sidebar
// ================================================================

function sidebarTaskTimestamp(task) {
  return completionTimeForTask(task) || task.started_at || task.created_at || null;
}

function statusLabelForTask(task) {
  const labels = {
    running: 'Running',
    pending: 'Queued',
    active: isBlockedTask(task) ? 'Waiting' : 'Scheduled',
    drafting: 'Planned',
    completed: 'Done',
    failed: 'Issue',
    cancelled: 'Cancelled',
  };
  return labels[task.status] || task.status;
}

function sidebarTitleForTask(task) {
  // Use explicit title if set
  if (task.title) return task.title;
  const result = resultForTask(task.id);
  if (['completed', 'failed', 'cancelled'].includes(task.status)) {
    if (result && result.summary) return shortTitle(result.summary);
    if (result && result.error) return shortTitle(result.error);
  }
  return shortTitle(task.description);
}

function sidebarMetaForTask(task) {
  const pieces = [`<span class="task-status-badge s-${escHtml(task.status)}">${escHtml(statusLabelForTask(task))}</span>`];

  if (task.status === 'running') {
    pieces.push(escHtml(taskScheduleLabel(task)));
    pieces.push(escHtml(elapsed(task.started_at || task.created_at)));
  } else if (['pending', 'active', 'drafting'].includes(task.status)) {
    pieces.push(escHtml(isBlockedTask(task) ? 'waiting on dependency' : taskScheduleLabel(task)));
    pieces.push(escHtml(relativeTime(task.created_at)));
  } else {
    const completedIso = completionTimeForTask(task);
    if (completedIso) pieces.push(escHtml(relativeTime(completedIso)));
  }

  // Show cost badge for completed/failed tasks that have results
  const result = resultForTask(task.id);
  if (result && result.cost && result.cost.cost_usd > 0) {
    pieces.push(`<span class="cost-badge">${fmtCost(result.cost.cost_usd)}</span>`);
  }

  if ((task.priority === 'high' || task.priority === 'urgent') && task.status !== 'running') {
    pieces.push(`<span class="priority-badge p-${escHtml(task.priority)}">${escHtml(task.priority)}</span>`);
  }

  return pieces.join('<span class="task-meta-sep">•</span>');
}

function sortRunningTasks(tasks) {
  return tasks.slice().sort((a, b) => {
    const aTime = new Date(a.started_at || a.created_at || 0).getTime();
    const bTime = new Date(b.started_at || b.created_at || 0).getTime();
    return aTime - bTime;
  });
}

function sortQueuedTasks(tasks) {
  return tasks.slice().sort((a, b) => {
    const aWeight = a.priority && typeof a.priority.weight === 'number' ? a.priority.weight : 0;
    const bWeight = b.priority && typeof b.priority.weight === 'number' ? b.priority.weight : 0;
    if (bWeight !== aWeight) return bWeight - aWeight;
    return new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime();
  });
}

function sortInactiveTasks(tasks) {
  return tasks.slice().sort((a, b) => {
    return new Date(sidebarTaskTimestamp(b) || 0).getTime() - new Date(sidebarTaskTimestamp(a) || 0).getTime();
  });
}

function buildSidebarProjectGroups(tasks) {
  const visibleTasks = getVisibleTasks(tasks);
  const groups = {};
  const searchOrStatusActive = Boolean(taskSearch) || statusFilter !== 'all';
  const scopedProject = projectFilter || null;
  const projectNames = new Set();

  if (scopedProject) {
    projectNames.add(scopedProject);
  } else {
    for (const project of allProjects) {
      projectNames.add(project.name);
    }
    for (const task of tasks) {
      projectNames.add(task.project || 'unknown');
    }
  }

  for (const name of projectNames) {
    groups[name] = {
      name,
      running: [],
      queued: [],
      inactive: [],
      latestActivity: null,
      hasSelectedTask: false,
    };
  }

  for (const task of visibleTasks) {
    const name = task.project || 'unknown';
    if (!groups[name]) {
      groups[name] = {
        name,
        running: [],
        queued: [],
        inactive: [],
        latestActivity: null,
        hasSelectedTask: false,
      };
    }

    if (task.status === 'running') {
      groups[name].running.push(task);
    } else if (['pending', 'active', 'drafting'].includes(task.status)) {
      groups[name].queued.push(task);
    } else {
      groups[name].inactive.push(task);
    }

    const activity = sidebarTaskTimestamp(task);
    if (activity && (!groups[name].latestActivity || new Date(activity).getTime() > new Date(groups[name].latestActivity).getTime())) {
      groups[name].latestActivity = activity;
    }
    if (task.id === selectedTaskId) {
      groups[name].hasSelectedTask = true;
    }
  }

  let names = Object.keys(groups);
  if (searchOrStatusActive || scopedProject) {
    names = names.filter(name => {
      const group = groups[name];
      return scopedProject ? name === scopedProject : (group.running.length + group.queued.length + group.inactive.length > 0);
    });
  }

  return names
    .map(name => {
      const group = groups[name];
      group.running = sortRunningTasks(group.running);
      group.queued = sortQueuedTasks(group.queued);
      group.inactive = sortInactiveTasks(group.inactive);
      return group;
    })
    .sort((a, b) => {
      if (!!b.running.length !== !!a.running.length) return b.running.length ? 1 : -1;
      if (!!b.queued.length !== !!a.queued.length) return b.queued.length ? 1 : -1;
      const aLatest = new Date(a.latestActivity || 0).getTime();
      const bLatest = new Date(b.latestActivity || 0).getTime();
      if (bLatest !== aLatest) return bLatest - aLatest;
      return a.name.localeCompare(b.name);
    });
}

function isProjectGroupExpanded(group) {
  // Manual toggle takes precedence
  if (Object.prototype.hasOwnProperty.call(projectGroupState, group.name)) {
    return !!projectGroupState[group.name];
  }
  if (group.hasSelectedTask) return true;
  if (projectFilter && group.name === projectFilter) return true;
  return group.running.length > 0 || group.queued.length > 0;
}

function isProjectHistoryExpanded(groupName) {
  return !!projectGroupHistoryState[groupName];
}

function setProjectGroupExpanded(name, expanded) {
  projectGroupState[name] = !!expanded;
  saveStoredMap(PROJECT_GROUP_STORAGE_KEY, projectGroupState);
  renderTaskList(allTasks);
}

function setProjectHistoryExpanded(name, expanded) {
  projectGroupHistoryState[name] = !!expanded;
  saveStoredMap(PROJECT_GROUP_HISTORY_STORAGE_KEY, projectGroupHistoryState);
  renderTaskList(allTasks);
}

function toggleProjectGroupByElement(element) {
  const project = element.dataset.project || '';
  const expanded = element.dataset.expanded === 'true';
  setProjectGroupExpanded(project, !expanded);
}

function toggleProjectHistoryByElement(element) {
  const project = element.dataset.project || '';
  const expanded = element.dataset.expanded === 'true';
  setProjectHistoryExpanded(project, !expanded);
}

function renderProjectGroupCounts(group) {
  const now = Date.now();
  const twelveHoursAgo = now - 12 * 3600000;
  const doneRecent = group.inactive.filter(t => {
    if (t.status !== 'completed') return false;
    const doneTime = completionTimeForTask(t);
    return doneTime && new Date(doneTime).getTime() >= twelveHoursAgo;
  }).length;

  const counts = [
    { tone: 'running', label: 'run', value: group.running.length },
    { tone: 'issues', label: 'issue', value: group.inactive.filter(t => ['failed', 'cancelled'].includes(t.status)).length },
    { tone: 'done', label: 'done', value: doneRecent },
  ];

  return counts.map(count => `
    <span class="project-cluster-pill ${count.tone}">
      <strong>${count.value}</strong>
      <span>${count.label}</span>
    </span>
  `).join('');
}

function renderSidebarTask(task) {
  const selectedClass = task.id === selectedTaskId ? ' selected' : '';
  const runningClass = task.status === 'running' ? ' task-running' : '';

  return `
    <button class="task-item${selectedClass}${runningClass}" data-id="${escHtml(task.id)}" onclick="selectTask('${escHtml(task.id)}')">
      <div class="task-status-dot ${statusDotClass(task.status)}"></div>
      <div class="task-item-text">
        <div class="task-item-title">${escHtml(sidebarTitleForTask(task))}</div>
        <div class="task-item-meta">${sidebarMetaForTask(task)}</div>
      </div>
    </button>
  `;
}

function renderProjectSpokeLabel(projectName) {
  // Find which spoke(s) this project's tasks have run on
  const targets = new Set();
  for (const t of allTasks) {
    if (t.project === projectName) {
      const tgt = t.target || 'local';
      if (tgt !== 'local' && tgt !== 'any') targets.add(tgt);
    }
  }
  if (!targets.size) return '';
  // Find spoke status for each target
  return [...targets].map(spokeId => {
    const spoke = allSpokes.find(s => s.id === spokeId);
    const isOnline = spoke && spoke.status !== 'offline';
    const dotCls = isOnline ? 'on' : 'off';
    return ` <span class="sb-project-spoke"><span class="sb-project-spoke-dot ${dotCls}"></span>${escHtml(spokeId)}</span>`;
  }).join('');
}

function renderTaskList(tasks) {
  const container = document.getElementById('task-sections');
  const groups = buildSidebarProjectGroups(tasks);

  if (!groups.length) {
    container.innerHTML = '<div class="task-list-empty">No matching projects</div>';
    return;
  }

  container.innerHTML = groups.map(group => {
    const iconMeta = projectIconMeta(group.name);
    const expanded = isProjectGroupExpanded(group);
    const historyExpanded = isProjectHistoryExpanded(group.name);
    const inactiveToShow = historyExpanded ? group.inactive : group.inactive.slice(0, SIDEBAR_RECENT_HISTORY_LIMIT);
    const hiddenCount = Math.max(group.inactive.length - SIDEBAR_RECENT_HISTORY_LIMIT, 0);
    const hasAnyTasks = group.running.length + group.queued.length + group.inactive.length > 0;
    const lastActivityLabel = group.latestActivity ? relativeTime(group.latestActivity) : 'No activity';
    const bodyItems = [
      ...group.running.map(renderSidebarTask),
      ...group.queued.map(renderSidebarTask),
      ...inactiveToShow.map(renderSidebarTask),
    ].join('');

    return `
      <section class="project-cluster${expanded ? ' expanded' : ''}${group.hasSelectedTask ? ' has-selection' : ''}">
        <button
          class="project-cluster-header"
          data-project="${escHtml(group.name)}"
          data-expanded="${expanded ? 'true' : 'false'}"
          onclick="toggleProjectGroupByElement(this)"
        >
          <div class="project-cluster-leading">
            <span class="project-cluster-icon ${iconMeta.badgeClass}" aria-hidden="true">${iconSvg(iconMeta.icon, 'tabler-icon')}</span>
            <div class="project-cluster-copy">
              <div class="project-cluster-title-row">
                <span class="project-cluster-title">${escHtml(projectDisplayName(group.name))}</span>
                ${(() => { const url = projectGitUrl(group.name); return url ? `<a class="project-git-link" href="${escHtml(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="Open on GitHub">
                  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
                </a>` : ''; })()}
              </div>
              <div class="project-cluster-last">${escHtml(lastActivityLabel)}${renderProjectSpokeLabel(group.name)}</div>
            </div>
          </div>
          <div class="project-cluster-trailing">
            <div class="project-cluster-pills">${renderProjectGroupCounts(group)}</div>
            <span class="project-cluster-chevron">${expanded ? '&#x25BE;' : '&#x25B8;'}</span>
          </div>
        </button>
        <div class="project-cluster-body${expanded ? '' : ' collapsed'}">
          ${hasAnyTasks ? bodyItems : '<div class="project-cluster-empty">No tasks yet</div>'}
          ${hiddenCount > 0 ? `
            <button
              class="project-cluster-more"
              data-project="${escHtml(group.name)}"
              data-expanded="${historyExpanded ? 'true' : 'false'}"
              onclick="toggleProjectHistoryByElement(this)"
            >
              ${historyExpanded ? 'Show less' : 'Show ' + hiddenCount + ' more'}
            </button>
          ` : ''}
        </div>
      </section>
    `;
  }).join('');
}

// ================================================================
// SIDEBAR FOOTER
// ================================================================

function renderSidebarSpokes() {
  const container = document.getElementById('sidebar-spokes');
  if (!container) return;

  if (!allSpokes.length) {
    container.style.display = 'none';
    return;
  }
  container.style.display = '';

  const online = allSpokes.filter(s => s.status !== 'offline');
  const busy = allSpokes.filter(s => s.running_tasks > 0);

  let html = '<div class="sb-spokes-label">Agents <span class="sb-spokes-count">' + online.length + '/' + allSpokes.length + ' online</span></div>';

  html += allSpokes.map(s => {
    const isOnline = s.status !== 'offline';
    const isBusy = s.running_tasks > 0;
    const statusCls = !isOnline ? 'off' : isBusy ? 'busy' : 'on';
    const caps = (s.capabilities || []).map(c => `<span class="sb-spoke-cap">${escHtml(c)}</span>`).join('');
    const taskInfo = isBusy ? `<span class="sb-spoke-tasks">${s.running_tasks} running</span>` : '';

    return `<div class="sb-spoke ${statusCls}">
      <span class="sb-spoke-dot ${statusCls}"></span>
      <span class="sb-spoke-id">${escHtml(s.id)}</span>
      ${caps}${taskInfo}
    </div>`;
  }).join('');

  container.innerHTML = html;
}

function renderSidebarFooter() {
  const footer = document.getElementById('sidebar-footer-text');
  const projCount = allProjects.length;
  const totalCost = allResults.reduce((sum, r) => sum + ((r.cost && r.cost.cost_usd) || 0), 0);
  const queueSize = allTasks.filter(t => ['pending', 'active', 'drafting', 'running'].includes(t.status)).length;
  footer.innerHTML = `<span>${projCount} project${projCount !== 1 ? 's' : ''}</span><span>${queueSize} live</span><span>${fmtCost(totalCost)}</span>`;
}

// ================================================================
// USAGE (Claude + Codex/OpenAI)
// ================================================================

async function fetchUtilization() {
  try {
    const [usageRes, histRes] = await Promise.allSettled([
      fetchJson('/usage'),
      fetchJson('/utilization/history'),
    ]);
    if (usageRes.status === 'fulfilled') { usageData = usageRes.value; renderUsage(); }
    if (histRes.status === 'fulfilled') { utilHistory = histRes.value || []; }
  } catch (e) {
    // Silently ignore — usage is non-critical
  }
}

function utilBarColor(pct) {
  if (pct >= 80) return 'red';
  if (pct >= 50) return 'yellow';
  return 'green';
}

function utilWindowLabel(name) {
  const labels = {
    five_hour: '5-Hour',
    seven_day: '7-Day (All)',
    seven_day_sonnet: '7-Day Sonnet',
    seven_day_opus: '7-Day Opus',
    seven_day_cowork: '7-Day Cowork',
    overage: 'Overage',
  };
  return labels[name] || name;
}

function formatCountdown(isoStr) {
  if (!isoStr) return '';
  const diff = new Date(isoStr) - Date.now();
  if (diff <= 0) return 'resetting...';
  const h = Math.floor(diff / 3600000);
  const m = Math.floor((diff % 3600000) / 60000);
  if (h > 0) return `resets in ${h}h ${m}m`;
  return `resets in ${m}m`;
}

function formatAbsoluteDateTime(isoStr) {
  if (!isoStr) return '';
  const parsed = new Date(isoStr);
  if (Number.isNaN(parsed.getTime())) return isoStr;
  return parsed.toLocaleString(undefined, {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
  });
}

function renderUsageSummaryPill(label, tone) {
  return `<span class="usage-summary-pill ${tone}">${escHtml(label)}</span>`;
}

function toggleUsageSection() {
  usageExpanded = !usageExpanded;
  saveStoredBoolean(USAGE_SECTION_STORAGE_KEY, usageExpanded);
  renderUsage();
}

function renderClaudeUsageCard(claude) {
  if (!claude) return '';

  const providerSubtitle = 'Anthropic OAuth usage windows';
  const plan = claude.plan
    ? `<span class="usage-provider-badge">${escHtml(String(claude.plan).charAt(0).toUpperCase() + String(claude.plan).slice(1))}</span>`
    : '';

  let body = '';
  if (claude.windows && claude.windows.length) {
    body = `
      <div class="utilization-bars">
        ${claude.windows.map(window => {
          const pct = Math.round(window.utilization * 100);
          const color = utilBarColor(pct);
          const countdown = formatCountdown(window.resets_at);
          const absoluteReset = formatAbsoluteDateTime(window.resets_at);
          return `
            <div class="util-bar-item">
              <div class="util-bar-label-row">
                <span class="util-bar-name">${escHtml(utilWindowLabel(window.name))}</span>
                <span class="util-bar-pct" style="color:var(--${color === 'yellow' ? 'amber' : color})">${pct}% used</span>
              </div>
              <div class="util-bar-track">
                <div class="util-bar-fill ${color}" style="width:${pct}%"></div>
              </div>
              ${(countdown || absoluteReset) ? `<div class="util-bar-reset">${escHtml([countdown, absoluteReset].filter(Boolean).join(' • '))}</div>` : ''}
            </div>
          `;
        }).join('')}
      </div>
    `;
  } else {
    body = `<div class="usage-note ${claude.error ? 'warn' : 'subtle'}">${escHtml(claude.error || 'No Claude usage windows available.')}</div>`;
  }

  return `
    <section class="usage-provider-card">
      <div class="usage-provider-head">
        <div>
          <div class="usage-provider-title">Claude Code</div>
          <div class="usage-provider-subtitle">${providerSubtitle}</div>
        </div>
        ${plan}
      </div>
      ${body}
    </section>
  `;
}

function renderUsageMetric(label, value) {
  return `
    <div class="usage-metric">
      <div class="usage-metric-label">${escHtml(label)}</div>
      <div class="usage-metric-value">${escHtml(value)}</div>
    </div>
  `;
}

function formatRemainingPercent(value) {
  if (value === null || value === undefined || value === '') return null;
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return null;
  const rounded = Math.abs(numeric - Math.round(numeric)) < 0.05
    ? String(Math.round(numeric))
    : numeric.toFixed(1);
  return `${rounded}% remaining`;
}

function renderUsageLimitMetric(label, value, meta = '', muted = false) {
  return `
    <div class="usage-metric">
      <div class="usage-metric-label">${escHtml(label)}</div>
      <div class="usage-metric-value${muted ? ' muted' : ''}">${escHtml(value)}</div>
      ${meta ? `<div class="usage-metric-sub">${escHtml(meta)}</div>` : ''}
    </div>
  `;
}

// (Codex usage card removed — Codex plan limits are not programmatically accessible)

function renderUsage() {
  const section = document.getElementById('usage-section');
  const body = document.getElementById('usage-body');
  const summary = document.getElementById('usage-summary');
  const toggle = document.getElementById('usage-toggle');
  const chevron = document.getElementById('usage-chevron');
  if (!section || !body || !summary || !toggle || !chevron) return;

  if (!usageData || !usageData.claude) {
    section.style.display = 'none';
    return;
  }

  section.style.display = '';
  toggle.setAttribute('aria-expanded', usageExpanded ? 'true' : 'false');
  chevron.innerHTML = usageExpanded ? '&#x25BE;' : '&#x25B8;';
  body.style.display = usageExpanded ? '' : 'none';

  const summaryBits = [];
  if (usageData.claude) {
    summaryBits.push(renderUsageSummaryPill(
      usageData.claude.available ? 'Claude live' : 'Claude unavailable',
      usageData.claude.available ? 'ok' : 'warn',
    ));
  }
  summary.innerHTML = summaryBits.join('');

  body.innerHTML = [
    renderClaudeUsageCard(usageData.claude),
  ].filter(Boolean).join('');
}

// ================================================================
// OVERVIEW — Command Center + Recent Activity + Stats
// ================================================================

function renderTimeline() {
  const section = document.getElementById('timeline-section');
  const chart = document.getElementById('timeline-chart');
  if (!section || !chart) return;

  if (!allTasks.length) { section.style.display = 'none'; return; }
  section.style.display = '';

  const days = 30;
  const now = new Date();
  const dates = [];
  for (let i = days - 1; i >= 0; i--) {
    const d = new Date(now);
    d.setDate(d.getDate() - i);
    dates.push(d.toISOString().slice(0, 10)); // "2026-03-22"
  }

  // Count tasks per project per day
  const projects = allProjects.map(p => p.name);
  const counts = {}; // "project|date" -> count
  let maxCount = 0;
  for (const t of allTasks) {
    const day = (t.created_at || '').slice(0, 10);
    const key = t.project + '|' + day;
    counts[key] = (counts[key] || 0) + 1;
    if (counts[key] > maxCount) maxCount = counts[key];
  }

  if (!projects.length) { section.style.display = 'none'; return; }

  // Date header row — use fixed 12px cells like GitHub (extra col for total)
  let html = '<div class="heatmap-wrap"><div class="heatmap-grid" style="grid-template-columns: 80px repeat(' + dates.length + ', 12px) 40px; gap: 3px; width: fit-content;">';
  html += '<div class="heatmap-corner"></div>';
  for (const date of dates) {
    const d = new Date(date + 'T00:00:00');
    const isToday = date === now.toISOString().slice(0, 10);
    // Show label only on Mondays to avoid overlap on narrow screens
    const showLabel = d.getDay() === 1;
    const label = showLabel ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
    html += '<div class="heatmap-date' + (isToday ? ' today' : '') + '">' + label + '</div>';
  }
  html += '<div class="heatmap-corner"></div>'; // empty cell above totals column

  // Project rows
  for (const proj of projects) {
    html += '<div class="heatmap-label">' + escHtml(projectDisplayName(proj)) + '</div>';
    let rowTotal = 0;
    for (const date of dates) {
      const count = counts[proj + '|' + date] || 0;
      rowTotal += count;
      const level = maxCount > 0 ? Math.min(4, Math.ceil(count / maxCount * 4)) : 0;
      html += '<div class="heatmap-cell level-' + (count > 0 ? level : 0) + '"'
        + ' onmouseenter="showHeatmapTip(event,\'' + escHtml(proj) + '\',\'' + date + '\',' + count + ')"'
        + ' onmouseleave="hideHeatmapTip()"'
        + '></div>';
    }
    html += '<div class="heatmap-total">' + rowTotal + '</div>';
  }
  html += '</div>';

  // Legend
  html += '<div class="heatmap-legend"><span class="heatmap-legend-label">Less</span>';
  html += '<div class="heatmap-cell level-0" style="width:10px;height:10px"></div>';
  html += '<div class="heatmap-cell level-1" style="width:10px;height:10px"></div>';
  html += '<div class="heatmap-cell level-2" style="width:10px;height:10px"></div>';
  html += '<div class="heatmap-cell level-3" style="width:10px;height:10px"></div>';
  html += '<div class="heatmap-cell level-4" style="width:10px;height:10px"></div>';
  html += '<span class="heatmap-legend-label">More</span></div>';

  html += '</div>';

  chart.innerHTML = html;
}

function showHeatmapTip(e, proj, date, count) {
  let tip = document.getElementById('heatmap-tip');
  if (!tip) { tip = document.createElement('div'); tip.id = 'heatmap-tip'; tip.className = 'heatmap-tooltip'; document.body.appendChild(tip); }
  tip.innerHTML = '<strong>' + count + '</strong> task' + (count !== 1 ? 's' : '') + '<br>' + proj + ' — ' + date;
  tip.style.display = 'block';
  tip.style.left = (e.clientX + 12) + 'px';
  tip.style.top = (e.clientY - 10) + 'px';
}

function hideHeatmapTip() {
  const tip = document.getElementById('heatmap-tip');
  if (tip) tip.style.display = 'none';
}

// Usage Over Time — SVG chart (VibeLens style)
let utilMetric = '5h'; // '5h' or '7d'
let utilTimeRange = '24h'; // '5h', '24h', '7d', 'all'

function renderUtilChart() {
  const wrap = document.getElementById('util-chart-wrap');
  const svgWrap = document.getElementById('util-chart-svg-wrap');
  const togglesEl = document.getElementById('util-chart-toggles');
  if (!wrap || !svgWrap) return;

  if (!utilHistory || utilHistory.length < 1) {
    wrap.style.display = 'none';
    return;
  }
  wrap.style.display = '';

  // Toggles: metric + time range
  if (togglesEl) {
    const metricBtns = ['5h', '7d'].map(m =>
      `<button class="uc-toggle ${m === utilMetric ? 'active' : ''}" onclick="utilMetric='${m}';renderUtilChart()">${m === '5h' ? '5-Hour' : '7-Day'}</button>`
    ).join('');
    const timeBtns = ['5h', '24h', '7d', 'all'].map(t =>
      `<button class="uc-toggle uc-time ${t === utilTimeRange ? 'active' : ''}" onclick="utilTimeRange='${t}';renderUtilChart()">${t === 'all' ? 'All' : t}</button>`
    ).join('');
    togglesEl.innerHTML = metricBtns + '<span class="uc-sep">|</span>' + timeBtns;
  }

  // Filter by time range
  const now = Date.now();
  const rangeMs = { '5h': 5*3600000, '24h': 24*3600000, '7d': 7*24*3600000, 'all': Infinity }[utilTimeRange] || 24*3600000;
  const recent = utilHistory.filter(s => (now - new Date(s.t).getTime()) < rangeMs);
  if (recent.length < 1) { wrap.style.display = 'none'; return; }

  // Chart dimensions (VibeLens: 800x200 viewBox)
  const W = 800, H = 200;
  const ML = 50, MR = 15, MT = 12, MB = 28;
  const PW = W - ML - MR, PH = H - MT - MB;

  const values = recent.map(s => s[utilMetric] || 0);
  const maxVal = Math.max(100, ...values); // Cap at 100% for utilization

  function px(i) { return ML + (recent.length > 1 ? (i / (recent.length - 1)) * PW : PW / 2); }
  function py(v) { return MT + PH - (v / maxVal) * PH; }

  // Build SVG
  let svg = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" class="uc-svg">`;

  // Gradient
  const color = utilMetric === '5h' ? '34,211,238' : '167,139,250';
  svg += `<defs><linearGradient id="ucGrad" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0%" stop-color="rgba(${color},0.3)"/>
    <stop offset="100%" stop-color="rgba(${color},0.02)"/>
  </linearGradient></defs>`;

  // Grid lines + Y labels
  for (let p = 0; p <= 100; p += 25) {
    const gy = py(p);
    svg += `<line x1="${ML}" y1="${gy}" x2="${ML + PW}" y2="${gy}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>`;
    if (p % 50 === 0) {
      svg += `<text x="${ML - 6}" y="${gy + 4}" fill="#a1a1aa" font-size="10" font-family="ui-monospace,monospace" text-anchor="end">${p}%</text>`;
    }
  }
  // Baseline
  svg += `<line x1="${ML}" y1="${py(0)}" x2="${ML + PW}" y2="${py(0)}" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`;

  // X labels
  const labelInterval = Math.max(1, Math.ceil(recent.length / 6));
  for (let i = 0; i < recent.length; i++) {
    if (i % labelInterval !== 0 && i !== recent.length - 1) continue;
    const d = new Date(recent[i].t);
    const label = d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
    svg += `<text x="${px(i)}" y="${H - 6}" fill="#a1a1aa" font-size="10" font-family="ui-monospace,monospace" text-anchor="middle">${label}</text>`;
  }

  // Area fill path
  if (recent.length > 1) {
    let areaD = `M ${px(0)} ${py(values[0])}`;
    for (let i = 1; i < recent.length; i++) areaD += ` L ${px(i)} ${py(values[i])}`;
    areaD += ` L ${px(recent.length - 1)} ${py(0)} L ${px(0)} ${py(0)} Z`;
    svg += `<path d="${areaD}" fill="url(#ucGrad)"/>`;

    // Line stroke
    let lineD = `M ${px(0)} ${py(values[0])}`;
    for (let i = 1; i < recent.length; i++) lineD += ` L ${px(i)} ${py(values[i])}`;
    svg += `<path d="${lineD}" fill="none" stroke="rgb(${color})" stroke-width="2" stroke-linejoin="round"/>`;
  }

  // Data point dots
  for (let i = 0; i < recent.length; i++) {
    svg += `<circle cx="${px(i)}" cy="${py(values[i])}" r="3" fill="rgb(${color})" class="uc-dot" data-idx="${i}"/>`;
  }

  // Invisible hover overlay
  svg += `<rect x="${ML}" y="${MT}" width="${PW}" height="${PH}" fill="transparent" class="uc-overlay"/>`;

  // Crosshair (hidden by default)
  svg += `<line id="uc-crosshair" x1="0" y1="${MT}" x2="0" y2="${MT + PH}" stroke="rgba(${color},0.4)" stroke-width="1" stroke-dasharray="4 3" style="display:none"/>`;

  svg += '</svg>';

  // Legend
  const last = recent[recent.length - 1];
  const legendColor = utilMetric === '5h' ? '#22d3ee' : '#a78bfa';
  svg += `<div class="uc-legend"><span style="color:${legendColor}">&#x25CF; ${utilMetric === '5h' ? '5-Hour' : '7-Day'}: ${last[utilMetric] != null ? last[utilMetric] + '%' : '—'}</span></div>`;

  svgWrap.innerHTML = svg;

  // Wire hover events
  const overlay = svgWrap.querySelector('.uc-overlay');
  const svgEl = svgWrap.querySelector('svg');
  if (overlay && svgEl) {
    const meta = { recent, values, px, py, color, ML, PW, MT, PH };
    overlay.addEventListener('mousemove', (e) => _ucHover(e, svgEl, meta));
    overlay.addEventListener('mouseleave', () => _ucLeave(svgEl));
  }
}

function _ucHover(e, svgEl, meta) {
  const rect = svgEl.getBoundingClientRect();
  const scaleX = 800 / rect.width;
  const mx = (e.clientX - rect.left) * scaleX;

  // Find nearest point
  let nearIdx = 0, nearDist = Infinity;
  for (let i = 0; i < meta.recent.length; i++) {
    const d = Math.abs(meta.px(i) - mx);
    if (d < nearDist) { nearDist = d; nearIdx = i; }
  }

  // Crosshair
  const ch = document.getElementById('uc-crosshair');
  if (ch) { ch.setAttribute('x1', meta.px(nearIdx)); ch.setAttribute('x2', meta.px(nearIdx)); ch.style.display = ''; }

  // Enlarge dot
  svgEl.querySelectorAll('.uc-dot').forEach((dot, i) => {
    dot.setAttribute('r', i === nearIdx ? '5' : '3');
  });

  // Tooltip
  const s = meta.recent[nearIdx];
  const time = new Date(s.t);
  const label = time.getHours().toString().padStart(2, '0') + ':' + time.getMinutes().toString().padStart(2, '0');
  let tip = document.getElementById('util-chart-tip');
  if (!tip) { tip = document.createElement('div'); tip.id = 'util-chart-tip'; tip.className = 'heatmap-tooltip'; document.body.appendChild(tip); }
  tip.innerHTML = `<strong>${label}</strong><br>5h: ${s['5h'] != null ? s['5h'] + '%' : '—'}<br>7d: ${s['7d'] != null ? s['7d'] + '%' : '—'}`;
  tip.style.display = 'block';
  tip.style.left = (e.clientX + 12) + 'px';
  tip.style.top = (e.clientY - 50) + 'px';
}

function _ucLeave(svgEl) {
  const ch = document.getElementById('uc-crosshair');
  if (ch) ch.style.display = 'none';
  svgEl.querySelectorAll('.uc-dot').forEach(dot => dot.setAttribute('r', '3'));
  const tip = document.getElementById('util-chart-tip');
  if (tip) tip.style.display = 'none';
}

// ================================================================
// AGENT NETWORK VISUALIZATION
// ================================================================

function netPlatformIcon(spoke) {
  const p = spoke.system_info && spoke.system_info.platform ? spoke.system_info.platform.toLowerCase() : '';
  if (p.includes('darwin') || p.includes('macos')) return '<span class="net-platform-ico" title="macOS"><svg width="11" height="13" viewBox="0 0 14 17" fill="currentColor"><path d="M13.2 13.3c-.3.7-.7 1.3-1.1 1.8-.6.8-1.2 1.3-1.7 1.5-.7.3-1.4.3-2.1 0-.5-.2-1-.3-1.5-.3s-1.1.1-1.6.3c-.5.2-.9.3-1.3.3-.8 0-1.5-.5-2.2-1.5C.9 14.2.3 12.6.1 11c-.2-1.2 0-2.3.5-3.2.5-.9 1.3-1.5 2.2-1.7.7-.2 1.4-.1 2.1.1.4.2.8.3 1.2.3.3 0 .8-.1 1.3-.3.9-.3 1.6-.4 2.3-.2 1 .3 1.8.8 2.3 1.7-1 .6-1.4 1.4-1.3 2.5 0 .9.3 1.6.9 2.2.3.3.6.5 1 .6l-.4.3zM9.9 1c0 .7-.2 1.3-.7 2-.6.7-1.2 1.1-2 1-.1-.6.1-1.3.5-1.9.5-.7 1.2-1.1 2-1.2.1.1.2.5.2 1.1z"/></svg></span>';
  if (p.includes('linux')) return '<span class="net-platform-ico" title="Linux"><svg width="11" height="13" viewBox="0 0 14 17" fill="currentColor"><path d="M7 0C4.8 0 3.5 1.8 3.5 4.2c0 1 .2 2 .6 3-.8.6-2 1.6-2.4 2.8-.3.8-.3 1.7.2 2.4.3.5.8.8 1.3.9-.1.4 0 .8.2 1.2.4.6 1.1.9 1.8.8.3 0 .6-.1.8-.3.3.5.8.8 1.4.9.2 0 .5 0 .7-.1.3.5.9.9 1.5.9.6 0 1.1-.3 1.4-.8.3.1.5.2.8.1.7-.1 1.2-.5 1.5-1.1.2-.4.2-.8.1-1.2.6-.1 1-.4 1.3-.9.5-.7.5-1.7.2-2.4-.4-1.2-1.6-2.2-2.4-2.8.4-1 .6-2 .6-3C11.5 1.8 10.2 0 7 0z"/></svg></span>';
  if (p.includes('windows') || p.includes('win')) return '<span class="net-platform-ico" title="Windows"><svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor"><path d="M0 2.3l6.5-.9v6.3H0V2.3zm7.3-1L16 0v7.7H7.3V1.3zM16 8.6V16l-8.7-1.2V8.6H16zM6.5 14.6L0 13.8V8.6h6.5v6z"/></svg></span>';
  return '';
}

function renderNetwork() {
  const section = document.getElementById('network-section');
  const graph = document.getElementById('network-graph');
  if (!section || !graph) return;

  if (!allSpokes.length) {
    section.style.display = 'none';
    return;
  }
  section.style.display = '';

  // Build project → spoke mapping from task history
  // Projects that had tasks with target=X belong to spoke X
  // Projects with target=local or no target belong to "hub"
  const projectSpokeMap = {}; // project name → Set of spoke ids
  for (const t of allTasks) {
    const target = t.target || 'local';
    if (!projectSpokeMap[t.project]) projectSpokeMap[t.project] = new Set();
    projectSpokeMap[t.project].add(target);
  }

  // Also assign all projects to hub by default
  const hubProjects = [];
  const spokeProjects = {}; // spoke id → [project names]
  for (const p of allProjects) {
    const targets = projectSpokeMap[p.name];
    let assigned = false;
    if (targets) {
      for (const t of targets) {
        if (t !== 'local' && t !== 'any') {
          if (!spokeProjects[t]) spokeProjects[t] = new Set();
          spokeProjects[t].add(p.name);
          assigned = true;
        }
      }
    }
    // All projects also show under hub (they're registered centrally)
    hubProjects.push(p.name);
  }

  // Find running tasks per spoke
  const spokeTaskMap = {};
  for (const t of allTasks) {
    if (t.status === 'running') {
      const target = t.target || 'local';
      if (!spokeTaskMap[target]) spokeTaskMap[target] = [];
      spokeTaskMap[target].push(t);
    }
  }

  // Animation for busy spokes
  const busySpokes = allSpokes.filter(s => s.running_tasks > 0 || (spokeTaskMap[s.id] && spokeTaskMap[s.id].length > 0));
  if (busySpokes.length && (!networkAnimTarget || Math.random() < 0.3)) {
    const pick = busySpokes[Math.floor(Math.random() * busySpokes.length)];
    networkAnimTarget = pick.id;
    networkAnimDir = Math.random() < 0.5 ? 'to-spoke' : 'to-hub';
  } else if (!busySpokes.length) {
    networkAnimTarget = null;
    networkAnimDir = 'idle';
  }

  const hubOnline = allSpokes.some(s => s.status === 'online' || s.status === 'busy');
  const anyBusy = allSpokes.some(s => s.running_tasks > 0);
  const hubTasks = spokeTaskMap['local'] || [];

  let html = '<div class="net-container">';

  // Hub node
  html += `
    <div class="net-hub">
      <div class="net-hub-icon${anyBusy ? ' net-hub-pulse' : ''}">
        <img src="/assets/dobby.png" alt="Dobby" class="net-hub-gif" />
      </div>
      <div class="net-hub-label">Hub</div>
      <div class="net-hub-status ${hubOnline ? 'online' : 'offline'}">${allSpokes.length} spoke${allSpokes.length !== 1 ? 's' : ''} connected</div>
      ${hubProjects.length ? `<div class="net-hub-projects">${hubProjects.map(p => `<span class="net-project-tag">${escHtml(projectDisplayName(p))}</span>`).join('')}</div>` : ''}
      ${hubTasks.length ? `<div class="net-spoke-tasks">${hubTasks.length} task${hubTasks.length !== 1 ? 's' : ''} running locally</div>` : ''}
    </div>
  `;

  // Spokes
  html += '<div class="net-spokes-row">';
  for (const spoke of allSpokes) {
    const isOnline = spoke.status !== 'offline';
    const isBusy = spoke.running_tasks > 0;
    const tasks = spokeTaskMap[spoke.id] || [];
    const isAnimTarget = networkAnimTarget === spoke.id;
    const statusClass = !isOnline ? 'offline' : isBusy ? 'busy' : 'online';
    const platform = spoke.system_info ? `${spoke.system_info.platform || ''} ${spoke.system_info.arch || ''}`.trim() : '';
    const projects = spokeProjects[spoke.id] ? [...spokeProjects[spoke.id]] : [];

    html += `
      <div class="net-spoke-col">
        <div class="net-connection ${statusClass} ${isAnimTarget ? 'net-anim-' + networkAnimDir : ''}">
          <div class="net-line"></div>
          ${isAnimTarget ? '<div class="net-packet"></div>' : ''}
        </div>
        <div class="net-spoke ${statusClass}">
          <div class="net-spoke-header">
            <span class="net-spoke-dot ${statusClass}"></span>
            <span class="net-spoke-name">${escHtml(spoke.id)}</span>
          </div>
          <div class="net-spoke-meta">${netPlatformIcon(spoke)}${escHtml(spoke.hostname || platform)}</div>
          <div class="net-spoke-caps">${(spoke.capabilities || []).map(c => `<span class="net-cap-badge">${escHtml(c)}</span>`).join('')}</div>
          ${projects.length ? `<div class="net-spoke-projects">${projects.map(p => `<span class="net-project-tag">${escHtml(projectDisplayName(p))}</span>`).join('')}</div>` : ''}
          ${isBusy ? `<div class="net-spoke-tasks">${spoke.running_tasks} task${spoke.running_tasks !== 1 ? 's' : ''} running</div>` : (isOnline ? '<div class="net-spoke-idle">idle</div>' : '')}
          ${tasks.length ? tasks.map(t => `<div class="net-spoke-task-item">${escHtml(t.project)}: ${escHtml((t.description || '').substring(0, 50))}</div>`).join('') : ''}
          <div class="net-spoke-heartbeat">${relativeTime(spoke.last_heartbeat)}</div>
        </div>
      </div>
    `;
  }
  html += '</div>';
  html += '</div>';

  graph.innerHTML = html;
}

function toggleGuideModal() {
  const modal = document.getElementById('guide-modal');
  const overlay = document.getElementById('guide-overlay');
  if (!modal || !overlay) return;
  const open = modal.classList.toggle('open');
  overlay.classList.toggle('open', open);
}

function renderOverview() {
  renderToolbar();
  renderUsage();
  renderLiveJobs();
  renderScheduled();
  renderOperations();
  renderBatches();
  renderTimeline();
  renderUtilChart();
  renderNetwork();
  renderProjectCards();
  renderActivityFeed();
  renderStats();
  fetchCostBreakdown();
}

// ================================================================
// OVERVIEW — Scheduled Items
// ================================================================

let scheduledItems = [];

async function fetchScheduledItems() {
  try {
    scheduledItems = await fetchJson('/schedule');
  } catch (e) {
    scheduledItems = [];
  }
  renderScheduled();
}

function renderScheduled() {
  const section = document.getElementById('scheduled-section');
  const container = document.getElementById('scheduled-list');
  if (!section || !container) return;

  if (!scheduledItems.length) { section.style.display = 'none'; return; }
  section.style.display = '';

  const kindIcon = k => k === 'task' ? '\u23f0' : '\u2611';
  const schedLabel = item => {
    if (item.schedule_type === 'cron') return 'cron: ' + (item.schedule_expr || '?');
    if (item.schedule_type === 'interval') return 'every ' + (item.schedule_expr || '?');
    if (item.schedule_type === 'after') return 'after dependency';
    if (item.schedule_type === 'due_date') return 'due';
    return item.schedule_type;
  };

  const statusColor = s => ({
    active: 'var(--green)', running: 'var(--amber)', pending: 'var(--blue)', open: 'var(--blue)'
  }[s] || 'var(--text-dim)');

  container.innerHTML = scheduledItems.slice(0, 20).map(item => {
    const nextStr = item.next_run_at ? relativeTime(item.next_run_at) : 'no date';
    const onclick = item.kind === 'task' ? `onclick="selectTask('${escHtml(item.id)}')"` : '';
    return `
      <div class="scheduled-item" ${onclick}>
        <span class="scheduled-icon">${kindIcon(item.kind)}</span>
        <span class="scheduled-dot" style="background:${statusColor(item.status)}"></span>
        ${item.project ? `<span class="activity-project-tag">${escHtml(item.project)}</span>` : ''}
        <span class="scheduled-title">${escHtml(item.title || item.id)}</span>
        <span class="scheduled-sched">${escHtml(schedLabel(item))}</span>
        <span class="scheduled-next">${escHtml(nextStr)}</span>
      </div>
    `;
  }).join('');
}

function renderBatches() {
  const section = document.getElementById('batches-section');
  const container = document.getElementById('batches-list');
  // Only show batches that have at least one non-done task (active batches)
  const active = (allBatches || []).filter(b => b.done < b.total);
  const recent = (allBatches || []).filter(b => b.done >= b.total).slice(0, 3);
  const batches = [...active, ...recent];

  if (!batches.length) { section.style.display = 'none'; return; }
  section.style.display = '';

  const statusColor = s => ({running:'var(--amber)',pending:'var(--blue)',completed:'var(--green)',failed:'var(--red)',cancelled:'var(--text-dim)'}[s]||'var(--text-muted)');

  container.innerHTML = batches.map(b => {
    const pctOk = b.total ? (b.completed / b.total * 100) : 0;
    const pctFail = b.total ? (b.failed / b.total * 100) : 0;
    const pctRun = b.total ? (b.running / b.total * 100) : 0;
    const allDone = b.done >= b.total;
    const label = allDone
      ? (b.failed ? `Done — ${b.failed} failed` : 'All succeeded')
      : `${b.done}/${b.total} done`;

    // Find matching tasks from allTasks
    const batchTasks = (allTasks || []).filter(t => t.batch_id === b.batch_id);

    return `<div class="batch-card" onclick="this.classList.toggle('expanded')">
      <div class="batch-card-header">
        <span class="batch-card-id">${b.batch_id}</span>
        <span class="batch-card-counts">${label}</span>
      </div>
      <div class="batch-progress-bar">
        <div class="batch-progress-ok" style="width:${pctOk}%"></div>
        <div class="batch-progress-fail" style="width:${pctFail}%"></div>
        <div class="batch-progress-running" style="width:${pctRun}%"></div>
      </div>
      <div class="batch-tasks-detail">
        ${batchTasks.map(t => `<div class="batch-task-row">
          <span class="status-dot" style="background:${statusColor(t.status)}"></span>
          <span style="font-family:var(--mono);color:var(--text-dim)">${t.id}</span>
          <span>${shortTitle(t.description)}</span>
          <span style="margin-left:auto;color:${statusColor(t.status)}">${t.status}</span>
        </div>`).join('')}
      </div>
    </div>`;
  }).join('');
}

function renderToolbar() {
  const searchInput = document.getElementById('task-search');
  const statusSelect = document.getElementById('status-filter-select');
  const summary = document.getElementById('toolbar-summary');

  if (searchInput && searchInput.value !== taskSearch) {
    searchInput.value = taskSearch;
  }

  if (statusSelect && statusSelect.value !== statusFilter) {
    statusSelect.value = statusFilter;
  }

  const visible = getVisibleTasks(allTasks);
  const running = visible.filter(t => t.status === 'running').length;
  const queued = queuedTasks(visible).length;
  const recurring = visible.filter(isRecurringTask).length;
  const failed = visible.filter(t => ['failed', 'cancelled'].includes(t.status)).length;
  if (summary) {
    summary.textContent = `${visible.length} shown | ${running} running | ${queued} queued | ${recurring} scheduled | ${failed} issues`;
  }
}

function renderOperations() {
  const container = document.getElementById('operations-grid');
  const visible = getVisibleTasks(allTasks);
  const running = visible.filter(t => t.status === 'running');
  const queued = queuedTasks(visible);
  const recurring = visible.filter(isRecurringTask);
  const blocked = visible.filter(isBlockedTask);
  const longRunning = running.filter(t => runningDurationSeconds(t) >= 1800);
  const failuresToday = visible.filter(t => ['failed', 'cancelled'].includes(t.status) && isToday(completionTimeForTask(t)));
  const urgentQueued = queued.filter(t => ['high', 'urgent'].includes(t.priority)).length;
  const activeProjects = new Set(running.map(t => t.project)).size;

  const cards = [
    {
      label: 'Running Now',
      value: running.length,
      valueClass: running.length ? 'amber' : 'green',
      tone: longRunning.length ? 'warn' : 'ok',
      meta: running.length
        ? `${activeProjects} project${activeProjects !== 1 ? 's' : ''} currently have active agents.`
        : 'No agents are currently running.',
    },
    {
      label: 'Queued Work',
      value: queued.length,
      valueClass: queued.length ? 'blue' : 'green',
      tone: 'ok',
      meta: queued.length
        ? `${urgentQueued} urgent or high-priority task${urgentQueued !== 1 ? 's' : ''} are waiting to run.`
        : 'The queue is clear.',
    },
    {
      label: 'Recurring Jobs',
      value: recurring.length,
      valueClass: recurring.length ? 'blue' : 'green',
      tone: 'ok',
      meta: recurring.length
        ? `${blocked.length} dependency-gated job${blocked.length !== 1 ? 's' : ''} are standing by inside active schedules.`
        : 'No cron or interval jobs are loaded.',
    },
    {
      label: 'Dependency Waits',
      value: blocked.length,
      valueClass: blocked.length ? 'amber' : 'green',
      tone: blocked.length ? 'warn' : 'ok',
      meta: blocked.length
        ? 'These AFTER tasks will become queueable when their prerequisite task completes.'
        : 'No tasks are blocked on dependencies.',
    },
    {
      label: 'Long Running',
      value: longRunning.length,
      valueClass: longRunning.length ? 'amber' : 'green',
      tone: longRunning.length ? 'warn' : 'ok',
      meta: longRunning.length
        ? longRunning.slice(0, 2).map(t => `${t.project}: ${elapsed(t.started_at || t.created_at)}`).join(' · ')
        : 'No running jobs have crossed the 30 minute mark.',
    },
    {
      label: 'Failures Today',
      value: failuresToday.length,
      valueClass: failuresToday.length ? 'red' : 'green',
      tone: failuresToday.length ? 'danger' : 'ok',
      meta: failuresToday.length
        ? failuresToday.slice(0, 2).map(t => `${t.project}: ${relativeTime(completionTimeForTask(t))}`).join(' · ')
        : 'No failures or cancellations recorded today.',
    },
  ];

  container.innerHTML = cards.map(card => `
    <div class="ops-card" data-tone="${card.tone}">
      <div class="ops-card-label">${escHtml(card.label)}</div>
      <div class="ops-card-value ${escHtml(card.valueClass)}">${escHtml(String(card.value))}</div>
      <div class="ops-card-meta">${escHtml(card.meta)}</div>
    </div>
  `).join('');
}

function renderLiveJobs() {
  const container = document.getElementById('live-jobs');
  const runningTasks = getVisibleTasks(allTasks).filter(t => t.status === 'running');

  if (!runningTasks.length) {
    container.innerHTML = `<div class="live-jobs-empty">No running jobs match the current filters. Pending and scheduled work remains visible in the queue on the left.</div>`;
    return;
  }

  const cards = runningTasks
    .sort((a, b) => runningDurationSeconds(b) - runningDurationSeconds(a))
    .map(t => {
      const result = resultForTask(t.id);
      const subtext = t.title || shortTitle((result && result.summary) || t.description);
      const scheduleLabel = taskScheduleLabel(t);
      const isLongRunning = runningDurationSeconds(t) >= 1800;
      const pills = [
        shortId(t.id),
        t.priority || 'normal',
        scheduleLabel,
        t.backend || 'claude',
        t.infra,
        t.timeout ? 'timeout ' + fmtDurationShort(t.timeout) : null,
      ].filter(Boolean);
      return `
        <div class="live-job-card${isLongRunning ? ' attention' : ''}">
          <div class="live-job-top">
            <div class="live-job-project">${escHtml(t.project)}</div>
            <div class="live-job-runtime">${elapsed(t.started_at || t.created_at)}</div>
          </div>
          <div class="live-job-title">${escHtml(subtext)}</div>
          <div class="live-job-meta">
            ${pills.map(pill => `<span class="live-job-pill">${escHtml(pill)}</span>`).join('')}
          </div>
          ${isLongRunning ? '<div class="live-job-note">Needs attention: running longer than 30 minutes.</div>' : ''}
          <div class="live-job-actions">
            <button class="live-job-btn" onclick="selectTask('${escHtml(t.id)}')">Open</button>
            <button class="live-job-btn" onclick="openTaskLogs('${escHtml(t.id)}')">Logs</button>
            <button class="live-job-btn danger" onclick="cancelTask('${escHtml(t.id)}')">Cancel</button>
          </div>
        </div>
      `;
    });

  container.innerHTML = cards.join('');
}

function renderProjectCards() {
  const container = document.getElementById('project-cards');

  const projectStats = {};
  for (const p of allProjects) {
    projectStats[p.name] = {
      running: 0,
      queued: 0,
      recurring: 0,
      blocked: 0,
      completed: 0,
      failed: 0,
      latestActivity: null,
    };
  }

  for (const t of allTasks) {
    const proj = t.project || 'unknown';
    if (!projectStats[proj]) {
      projectStats[proj] = { running: 0, queued: 0, recurring: 0, blocked: 0, completed: 0, failed: 0, latestActivity: null };
    }
    if (t.status === 'running') projectStats[proj].running++;
    else if (['pending', 'active', 'drafting'].includes(t.status)) projectStats[proj].queued++;
    else if (t.status === 'completed') {
      if (isToday(completionTimeForTask(t))) projectStats[proj].completed++;
    } else if (['failed', 'cancelled'].includes(t.status)) {
      if (isToday(completionTimeForTask(t))) projectStats[proj].failed++;
    }
    if (isRecurringTask(t)) projectStats[proj].recurring++;
    if (isBlockedTask(t)) projectStats[proj].blocked++;

    const activityIso = completionTimeForTask(t) || t.started_at || t.created_at;
    if (activityIso) {
      const prev = projectStats[proj].latestActivity;
      if (!prev || new Date(activityIso).getTime() > new Date(prev).getTime()) {
        projectStats[proj].latestActivity = activityIso;
      }
    }
  }

  const names = Object.keys(projectStats).sort((a, b) => {
    const left = projectStats[a];
    const right = projectStats[b];
    if (right.running !== left.running) return right.running - left.running;
    if (right.queued !== left.queued) return right.queued - left.queued;
    if (right.failed !== left.failed) return right.failed - left.failed;
    return a.localeCompare(b);
  });

  if (names.length === 0) {
    container.innerHTML = '<div class="no-activity">No projects registered</div>';
    return;
  }

  container.innerHTML = names.map(name => {
    const s = projectStats[name];
    const isActive = projectFilter === name;
    const hasPulse = s.running > 0;
    const latestText = s.latestActivity ? `Last activity ${relativeTime(s.latestActivity)}` : 'No activity yet';
    const firstRunningTask = allTasks
      .filter(t => t.project === name && t.status === 'running')
      .sort((a, b) => runningDurationSeconds(b) - runningDurationSeconds(a))[0] || null;
    const headline = s.running > 0
      ? `${s.running} live`
      : (s.queued > 0 ? `${s.queued} queued` : 'idle');

    return `
      <div class="project-card${isActive ? ' active-filter' : ''}" onclick="filterByProject('${escHtml(name)}')">
        <div class="project-card-head">
          <div>
            <div class="project-card-name">
              ${escHtml(name)}
              ${hasPulse ? '<span class="pulsing-dot"></span>' : ''}
            </div>
            <div class="project-card-sub">${escHtml(latestText)}</div>
          </div>
          <div class="project-card-pill">${escHtml(headline)}</div>
        </div>
        <div class="project-card-stats">
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--amber)"></span>
            <span class="stat-val">${s.running}</span>
            <span class="stat-label">running</span>
          </div>
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--blue)"></span>
            <span class="stat-val">${s.queued}</span>
            <span class="stat-label">queued</span>
          </div>
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--blue)"></span>
            <span class="stat-val">${s.recurring}</span>
            <span class="stat-label">scheduled</span>
          </div>
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--amber)"></span>
            <span class="stat-val">${s.blocked}</span>
            <span class="stat-label">blocked</span>
          </div>
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--green)"></span>
            <span class="stat-val">${s.completed}</span>
            <span class="stat-label">done today</span>
          </div>
          <div class="project-card-stat">
            <span class="stat-dot" style="background:var(--red)"></span>
            <span class="stat-val">${s.failed}</span>
            <span class="stat-label">issues today</span>
          </div>
        </div>
        <div class="project-card-actions">
          <button class="project-card-btn primary" onclick="event.stopPropagation(); filterByProject('${escHtml(name)}')">
            ${isActive ? 'Clear focus' : 'Focus'}
          </button>
          ${firstRunningTask ? `<button class="project-card-btn" onclick="event.stopPropagation(); openRunningTaskForProject('${escHtml(name)}')">Open running</button>` : ''}
          ${s.running ? `<button class="project-card-btn warn" onclick="event.stopPropagation(); cancelRunningForProject('${escHtml(name)}')">Stop running</button>` : ''}
        </div>
      </div>
    `;
  }).join('');
}

function filterByProject(name) {
  if (projectFilter === name) {
    projectFilter = null; // toggle off
  } else {
    projectFilter = name;
  }
  renderOverview();
  renderTaskList(allTasks);
  renderStatusBanner();
  renderMainHeader();
}

function openRunningTaskForProject(name) {
  const running = allTasks
    .filter(t => t.project === name && t.status === 'running')
    .sort((a, b) => runningDurationSeconds(b) - runningDurationSeconds(a));
  if (!running.length) {
    projectFilter = name;
    renderOverview();
    renderTaskList(allTasks);
    renderStatusBanner();
    renderMainHeader();
    setTransientBanner('No running tasks for ' + name + '. Focused the queue instead.', 'warn');
    return;
  }
  selectTask(running[0].id);
  switchTabByName('logs');
}

async function cancelRunningForProject(name) {
  const running = allTasks.filter(t => t.project === name && t.status === 'running');
  if (!running.length) {
    setTransientBanner('No running tasks for ' + name + '.', 'warn');
    return;
  }

  const label = running.length === 1 ? 'task' : 'tasks';
  const confirmed = window.confirm('Cancel ' + running.length + ' running ' + label + ' for ' + name + '?');
  if (!confirmed) return;

  try {
    for (const task of running) {
      await fetchJson(`/tasks/${task.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: 'cancelled' }),
      });
    }
    setTransientBanner('Cancelled ' + running.length + ' running ' + label + ' for ' + name + '.', 'success');
    await refreshAll();
  } catch (e) {
    console.error('Bulk cancel failed:', e);
    setTransientBanner('Bulk cancel failed for ' + name + ': ' + e.message, 'error');
  }
}

function renderActivityFeed() {
  const feed = document.getElementById('activity-feed');
  const recentDone = getVisibleTasks(allTasks)
    .filter(t => ['completed', 'failed', 'cancelled'].includes(t.status))
    .sort((a, b) => {
      const aT = completionTimeForTask(a) ? new Date(completionTimeForTask(a)).getTime() : 0;
      const bT = completionTimeForTask(b) ? new Date(completionTimeForTask(b)).getTime() : 0;
      return bT - aT;
    })
    .slice(0, 10);

  if (!recentDone.length) {
    feed.innerHTML = '<div class="no-activity">No recent completed/failed tasks</div>';
    return;
  }

  feed.innerHTML = recentDone.map(t => {
    const dotColor = {
      completed: 'var(--green)',
      failed: 'var(--red)',
      cancelled: 'var(--text-dim)',
    }[t.status] || 'var(--text-dim)';

    const result = resultForTask(t.id);
    const summary = (result && result.summary) ? result.summary : t.description;

    return `
      <div class="activity-item" onclick="selectTask('${escHtml(t.id)}')">
        <div class="activity-dot" style="background:${dotColor}"></div>
        <span class="activity-project-tag">${escHtml(t.project)}</span>
        <div class="activity-text">${escHtml(summary)}</div>
        <div class="activity-time">${relativeTime(completionTimeForTask(t))}</div>
      </div>
    `;
  }).join('');
}

function renderStats() {
  const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  const visibleTasks = getVisibleTasks(allTasks);
  const visibleIds = new Set(visibleTasks.map(t => t.id));
  const visibleResults = allResults.filter(r => visibleIds.has(r.task_id));

  // Completed today
  const completedToday = visibleTasks.filter(t => t.status === 'completed' && isToday(completionTimeForTask(t))).length;
  set('ov-completed-today', completedToday);

  // Tasks today (all statuses)
  const tasksToday = visibleTasks.filter(t => isToday(t.created_at)).length;
  set('ov-tasks-today', tasksToday);

  // Cost today vs total
  let costToday = 0;
  let costTotal = 0;
  let totalTokens = 0;
  let durations = [];

  for (const r of visibleResults) {
    const cost = (r.cost && r.cost.cost_usd) || 0;
    costTotal += cost;
    totalTokens += (r.cost && r.cost.total_tokens) || 0;

    // Check if this result is from today
    if (isToday(r.completed_at)) {
      costToday += cost;
    }

    // Duration calculation
    const task = allTasks.find(t => t.id === r.task_id);
    const startIso = r.started_at || (task && task.started_at) || (task && task.created_at);
    if (startIso && r.completed_at) {
      const ms = new Date(r.completed_at).getTime() - new Date(startIso).getTime();
      if (ms > 0) durations.push(ms / 1000);
    }
  }

  set('ov-cost-today', fmtCost(costToday));
  set('ov-cost', fmtCost(costTotal));
  set('ov-tokens', fmtTokens(totalTokens));

  const avgDur = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
  set('ov-avg-duration', fmtDurationShort(avgDur));
}

// ================================================================
// COST BREAKDOWN
// ================================================================

let costBreakdownData = null;

async function fetchCostBreakdown() {
  try {
    costBreakdownData = await fetchJson('/stats/cost');
    renderCostBreakdown();
  } catch (e) {
    // Non-critical, ignore
  }
}

function renderCostBreakdown() {
  const d = costBreakdownData;
  if (!d) return;

  const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  set('cb-cost-today', fmtCost(d.cost_today));
  set('cb-cost-week', fmtCost(d.cost_week));
  set('cb-cost-total', fmtCost(d.cost_total));
  set('cb-tokens-today', fmtTokens(d.tokens_today));

  // Per-project table
  const container = document.getElementById('cost-by-project');
  if (!container) return;

  const projects = Object.entries(d.by_project || {})
    .sort((a, b) => b[1].cost_usd - a[1].cost_usd);

  if (!projects.length) {
    container.innerHTML = '';
    return;
  }

  let html = '<table class="cost-project-table"><thead><tr>' +
    '<th>Project</th><th>Today</th><th>This Week</th><th>All Time</th><th>Tasks</th><th>Tokens</th>' +
    '</tr></thead><tbody>';
  for (const [name, info] of projects) {
    html += `<tr>
      <td class="cost-project-name">${escHtml(name)}</td>
      <td>${fmtCost(info.cost_today)}</td>
      <td>${fmtCost(info.cost_week)}</td>
      <td>${fmtCost(info.cost_usd)}</td>
      <td>${info.task_count}</td>
      <td>${fmtTokens(info.total_tokens)}</td>
    </tr>`;
  }
  html += '</tbody></table>';
  container.innerHTML = html;

  // Subscription note
  const noteEl = document.getElementById('cost-subscription-note');
  if (noteEl && d.cost_total > 0) {
    noteEl.style.display = '';
    noteEl.innerHTML = `<span class="sub-note-icon">&#x1F4B3;</span> Equivalent API cost: <strong>${fmtCost(d.cost_total)}</strong> (covered by Claude Max subscription)`;
  }
}

// ================================================================
// TASK SELECTION
// ================================================================

function selectTask(taskId) {
  if (taskId === selectedTaskId) return;

  selectedTaskId = taskId;
  expandedLogLines.clear();
  parsedLogEvents = [];
  expandedEvents.clear();
  selectedTask = allTasks.find(t => t.id === taskId) || null;
  selectedResult = resultForTask(taskId);

  if (isMobileLayout()) {
    closeSidebarOnMobile();
  }

  renderTaskList(allTasks);

  // Show detail, hide overview
  document.getElementById('overview').classList.remove('visible');
  document.getElementById('detail').classList.add('visible');

  // Render detail
  renderDetailTopbar();

  // Default to timeline tab for all tasks
  switchTabByName('timeline');

  // Fetch full task details + result + logs
  fetchTaskDetail(taskId);

  // Start log polling if running
  startLogPolling();

  // Start detail-status polling for running tasks
  detailStatusData = null;
  startDetailStatusPolling();
}

function deselectTask() {
  selectedTaskId = null;
  selectedTask = null;
  selectedResult = null;
  expandedLogLines.clear();
  parsedLogEvents = [];
  expandedEvents.clear();
  detailStatusData = null;

  renderTaskList(allTasks);

  document.getElementById('overview').classList.add('visible');
  document.getElementById('detail').classList.remove('visible');

  stopLogPolling();
  stopDetailStatusPolling();
  hideLiveActivity();
  renderOverview();
}

function openTaskLogs(taskId) {
  if (selectedTaskId === taskId) {
    document.getElementById('overview').classList.remove('visible');
    document.getElementById('detail').classList.add('visible');
    switchTabByName('logs');
    startLogPolling();
    return;
  }
  selectTask(taskId);
  switchTabByName('logs');
}

// ================================================================
// DETAIL VIEW
// ================================================================

function renderDetailTopbar() {
  if (!selectedTask) return;

  const t = selectedTask;
  document.getElementById('detail-id').textContent = t.id;
  document.getElementById('detail-project').textContent = t.project;
  document.getElementById('detail-badge').innerHTML =
    `<span class="detail-badge detail-badge-${t.status}">${t.status}</span>`;

  const el = document.getElementById('detail-elapsed');
  if (t.status === 'running') {
    el.textContent = elapsed(t.started_at || t.created_at);
  } else if (selectedResult && selectedResult.completed_at) {
    const runSeconds = durationSecondsForTask(t);
    el.textContent = runSeconds ? fmtDurationShort(runSeconds) + ' runtime' : '';
  } else {
    el.textContent = '';
  }

  // Action buttons
  const actions = document.getElementById('detail-actions');
  let btns = '';
  if (t.status === 'running') {
    btns += `<button class="detail-btn danger" onclick="cancelTask('${escHtml(t.id)}')">Cancel</button>`;
  }
  if (t.status === 'failed' || t.status === 'cancelled') {
    btns += `<button class="detail-btn" onclick="retryTask('${escHtml(t.id)}')">Retry</button>`;
  }
  actions.innerHTML = btns;
}

async function cancelTask(taskId) {
  try {
    await fetchJson(`/tasks/${taskId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ status: 'cancelled' }),
    });
    setTransientBanner('Task ' + taskId + ' cancelled.', 'success');
    await refreshAll();
  } catch (e) {
    console.error('Cancel failed:', e);
    setTransientBanner('Cancel failed for ' + taskId + ': ' + e.message, 'error');
  }
}

async function retryTask(taskId) {
  try {
    const updated = await fetchJson(`/tasks/${taskId}/retry`, {
      method: 'POST',
    });
    const label = updated.status === 'active' ? 'reactivated' : 'queued';
    setTransientBanner('Task ' + taskId + ' ' + label + '.', 'success');
    await refreshAll();
  } catch (e) {
    console.error('Retry failed:', e);
    setTransientBanner('Retry failed for ' + taskId + ': ' + e.message, 'error');
  }
}

// ================================================================
// TABS
// ================================================================

function switchTab(el) {
  const tab = el.dataset.tab;
  switchTabByName(tab);
}

function switchTabByName(name) {
  document.querySelectorAll('.detail-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));

  if (name === 'details') renderDetailsTab();
  if (name === 'timeline') renderTimelineTab();
  if (name === 'logs') {
    renderLogs();
    startLogPolling();
  }
}

// ================================================================
// STRUCTURED LOG PARSING
// ================================================================

function detectLogFormat(lines) {
  if (!lines || lines.length === 0) return 'raw';

  // Check first few JSON lines for format signatures
  for (let i = 0; i < Math.min(5, lines.length); i++) {
    const line = (lines[i] || '').trim();
    if (!line.startsWith('{')) continue;
    let obj;
    try { obj = JSON.parse(line); } catch (e) { continue; }

    // Codex: has sandbox/reasoning summaries config, or {id, msg} event format
    if (obj.sandbox || obj['reasoning summaries'] || obj.approval) return 'codex';
    if ('id' in obj && 'msg' in obj) return 'codex';

    // Claude stream-json: has type=system/assistant/user/result with session_id
    if (obj.type === 'system' && obj.subtype === 'init') return 'claude';
    if (obj.type === 'system' && obj.subtype === 'hook_started') return 'claude';
    if (obj.type === 'assistant' && obj.message) return 'claude';
    if (obj.type === 'user' && obj.message) return 'claude';
    if (obj.type === 'result') return 'claude';
  }
  return 'raw';
}

function parseCodexEvents(lines) {
  const events = [];
  let pendingCommands = {}; // call_id → event index

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line) continue;

    let obj;
    try { obj = JSON.parse(line); } catch (e) { continue; }

    // Skip config/prompt lines (no msg field)
    const msg = obj.msg;
    if (!msg || typeof msg !== 'object') continue;

    const type = msg.type || '';

    if (type === 'agent_message') {
      const text = msg.message || '';
      if (text.trim()) {
        events.push({ type: 'message', content: text, line: i });
      }
    } else if (type === 'agent_reasoning_section_break') {
      // Visual separator before reasoning
    } else if (type === 'agent_reasoning') {
      const text = msg.text || '';
      if (text.trim()) {
        // Merge consecutive reasoning events
        const last = events.length > 0 ? events[events.length - 1] : null;
        if (last && last.type === 'thinking') {
          last.content += '\n' + text;
        } else {
          events.push({ type: 'thinking', content: text, line: i });
        }
      }
    } else if (type === 'exec_command_begin') {
      const cmd = msg.command || [];
      // Format command: if zsh -lc "...", extract the inner command
      let cmdStr = '';
      if (Array.isArray(cmd) && cmd.length >= 3 && (cmd[0] === 'zsh' || cmd[0] === 'bash') && cmd[1] === '-lc') {
        cmdStr = cmd.slice(2).join(' ');
      } else if (Array.isArray(cmd)) {
        cmdStr = cmd.join(' ');
      } else {
        cmdStr = String(cmd);
      }
      const idx = events.length;
      events.push({
        type: 'command',
        command: cmdStr,
        cwd: msg.cwd || '',
        output: '',
        exitCode: null,
        callId: msg.call_id,
        line: i,
      });
      if (msg.call_id) pendingCommands[msg.call_id] = idx;
    } else if (type === 'exec_command_output_delta') {
      // Streaming output chunk — append decoded base64 to the pending command
      const callId = msg.call_id;
      const idx = pendingCommands[callId];
      if (idx !== undefined && events[idx]) {
        let chunk = msg.chunk || '';
        try { chunk = atob(chunk); } catch (e) { /* not base64, use raw */ }
        events[idx].output = (events[idx].output || '') + chunk;
      }
    } else if (type === 'exec_command_end') {
      const callId = msg.call_id;
      const idx = pendingCommands[callId];
      if (idx !== undefined && events[idx]) {
        events[idx].exitCode = msg.exit_code;
        // If exec_command_end has explicit stdout/aggregated_output, prefer it over deltas
        let output = msg.stdout || '';
        if (!output && msg.aggregated_output) {
          output = typeof msg.aggregated_output === 'string'
            ? msg.aggregated_output
            : (msg.aggregated_output.stdout || '');
        }
        if (output) events[idx].output = output;
        delete pendingCommands[callId];
      }
    } else if (type === 'patch_apply_begin') {
      // File patch — show which files are being modified
      const changes = msg.changes || {};
      const files = Object.keys(changes).map(f => f.split('/').slice(-2).join('/'));
      events.push({
        type: 'command',
        command: 'patch: ' + files.join(', '),
        output: '',
        exitCode: null,
        callId: msg.call_id,
        line: i,
      });
      if (msg.call_id) pendingCommands[msg.call_id] = events.length - 1;
    } else if (type === 'patch_apply_end') {
      const callId = msg.call_id;
      const idx = pendingCommands[callId];
      if (idx !== undefined && events[idx]) {
        events[idx].exitCode = 0;
        events[idx].output = msg.stdout || '';
        delete pendingCommands[callId];
      }
    } else if (type === 'turn_diff') {
      // Show a summary of the diff
      const diff = msg.unified_diff || '';
      const diffFiles = [];
      for (const line of diff.split('\n')) {
        if (line.startsWith('diff --git')) {
          const m = line.match(/b\/(.+)$/);
          if (m) diffFiles.push(m[1]);
        }
      }
      if (diffFiles.length) {
        events.push({
          type: 'info',
          content: 'Files changed: ' + diffFiles.join(', '),
          line: i,
        });
      }
    } else if (type === 'token_count') {
      const info = msg.info || {};
      const usage = info.total_token_usage || {};
      if (usage.input_tokens || usage.output_tokens) {
        events.push({
          type: 'info',
          content: `Tokens: ${(usage.input_tokens || 0).toLocaleString()} input, ${(usage.output_tokens || 0).toLocaleString()} output` +
            (usage.reasoning_output_tokens ? ` (${usage.reasoning_output_tokens.toLocaleString()} reasoning)` : ''),
          line: i,
        });
      }
    } else if (type === 'task_started') {
      events.push({ type: 'system', content: 'Task started', line: i });
    }
  }

  return events;
}

function parseClaudeEvents(lines) {
  const events = [];

  // Build tool_result map from user messages (tool_use_id → content)
  const toolResultMap = {};
  // Parse all JSONL events
  const parsed = [];
  let resultEnvelope = null;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line) continue;
    let obj;
    try { obj = JSON.parse(line); } catch (e) { continue; }
    parsed.push({ obj, line: i });

    if (obj.type === 'result') {
      resultEnvelope = obj;
      continue;
    }

    // Build tool result map from user messages
    if (obj.type === 'user') {
      const content = (obj.message && obj.message.content) || [];
      for (const block of content) {
        if (block.type === 'tool_result' && block.tool_use_id) {
          const text = typeof block.content === 'string' ? block.content
            : Array.isArray(block.content) ? block.content.map(c => c.text || '').join('\n') : '';
          toolResultMap[block.tool_use_id] = {
            content: text,
            isError: !!block.is_error,
            timestamp: obj.timestamp || null,
          };
        }
      }
    }
  }

  // Now process events in order to build the step timeline
  let sessionModel = null;

  for (const { obj, line: lineIdx } of parsed) {
    // System init
    if (obj.type === 'system' && obj.subtype === 'init') {
      sessionModel = obj.model || null;
      events.push({ type: 'system', content: 'Session started' + (obj.model ? ` (${obj.model})` : ''), line: lineIdx, timestamp: null });
      continue;
    }

    // Assistant messages: extract thinking, text, tool_use blocks
    if (obj.type === 'assistant') {
      const msg = obj.message || {};
      const content = msg.content || [];
      const usage = msg.usage || {};
      const uuid = obj.uuid || '';

      for (const block of content) {
        if (block.type === 'thinking' && block.thinking) {
          events.push({
            type: 'thinking',
            content: block.thinking,
            line: lineIdx,
            timestamp: null,
            uuid,
          });
        } else if (block.type === 'text' && block.text) {
          events.push({
            type: 'message',
            content: block.text,
            line: lineIdx,
            timestamp: null,
            uuid,
          });
        } else if (block.type === 'tool_use') {
          const toolName = block.name || 'unknown';
          const toolId = block.id || '';
          const input = block.input || {};

          // Format input preview
          let inputPreview = '';
          if (typeof input === 'string') {
            inputPreview = input.substring(0, 200);
          } else if (input.command) {
            inputPreview = input.command;
          } else if (input.file_path) {
            inputPreview = input.file_path;
          } else if (input.pattern) {
            inputPreview = input.pattern + (input.path ? ' in ' + input.path : '');
          } else if (input.old_string !== undefined) {
            inputPreview = 'edit in ' + (input.file_path || '');
          } else {
            try { inputPreview = JSON.stringify(input).substring(0, 200); } catch (e) { inputPreview = '...'; }
          }

          // Look up the tool result
          const result = toolResultMap[toolId];
          const output = result ? result.content : '';
          const isError = result ? result.isError : false;
          const resultTimestamp = result ? result.timestamp : null;

          events.push({
            type: 'command',
            command: inputPreview,
            toolName,
            toolId,
            toolInput: input,
            output: (isError ? '[ERROR] ' : '') + output,
            exitCode: isError ? 1 : (result ? 0 : null),
            line: lineIdx,
            timestamp: resultTimestamp,
            uuid,
          });
        }
      }
      continue;
    }

    // Rate limit events
    if (obj.type === 'rate_limit_event') {
      const info = obj.rate_limit_info || {};
      if (info.message) {
        events.push({ type: 'info', content: 'Rate limit: ' + info.message, line: lineIdx, timestamp: null });
      }
      continue;
    }

    // Result envelope — add session summary
    if (obj.type === 'result') {
      const meta = [];
      if (obj.num_turns) meta.push(`${obj.num_turns} turns`);
      if (obj.duration_ms) meta.push(fmtDurationShort(obj.duration_ms / 1000));
      if (obj.total_cost_usd) meta.push(`$${obj.total_cost_usd.toFixed(4)}`);
      if (meta.length) {
        events.push({ type: 'info', content: 'Session complete: ' + meta.join(' · '), line: lineIdx, timestamp: null });
      }
      continue;
    }
  }

  // Fallback: if no streaming events found, try legacy single-envelope parsing
  if (events.length === 0 && resultEnvelope) {
    const resultText = resultEnvelope.result || '';
    if (resultText) {
      const dobbyMatch = resultText.match(/---DOBBY_RESULT---\s*([\s\S]*?)\s*---END_DOBBY_RESULT---/);
      let narrative = resultText;
      if (dobbyMatch) {
        narrative = resultText.substring(0, resultText.indexOf('---DOBBY_RESULT---')).trim();
      }

      if (narrative) {
        // Split narrative into paragraphs for readability
        const paragraphs = narrative.split(/\n\n+/);
        for (const p of paragraphs) {
          const trimmed = p.trim();
          if (!trimmed) continue;
          events.push({ type: 'message', content: trimmed, line: 0 });
        }
      }

      // Parse DOBBY_RESULT as structured result
      if (dobbyMatch) {
        try {
          const resultData = JSON.parse(dobbyMatch[1]);
          let summary = `Status: ${resultData.status || 'unknown'}`;
          if (resultData.summary) summary += `\n${resultData.summary}`;
          if (resultData.files_changed && resultData.files_changed.length) {
            summary += `\nFiles: ${resultData.files_changed.join(', ')}`;
          }
          if (resultData.pr_url) summary += `\nPR: ${resultData.pr_url}`;
          events.push({ type: 'result', content: summary, line: 0, data: resultData });
        } catch (e) {
          events.push({ type: 'result', content: dobbyMatch[1], line: 0 });
        }
      }
    }

    // Status
    const statusLabel = resultEnvelope.is_error ? 'Error' : (resultEnvelope.subtype === 'success' ? 'Success' : resultEnvelope.subtype);
    events.push({
      type: resultEnvelope.is_error ? 'error' : 'system',
      content: `Result: ${statusLabel}`,
      line: 0,
    });
  }

  return events;
}

function parseRawLogEvents(lines) {
  const events = [];
  let currentBlock = [];

  function flushBlock() {
    if (currentBlock.length === 0) return;
    const text = currentBlock.join('\n').trim();
    if (!text) { currentBlock = []; return; }

    // Detect error lines
    if (/\berror\b/i.test(text) && text.length < 500) {
      events.push({ type: 'error', content: text, line: 0 });
    } else {
      events.push({ type: 'message', content: text, line: 0 });
    }
    currentBlock = [];
  }

  for (const line of lines) {
    if (!line.trim()) {
      flushBlock();
    } else {
      currentBlock.push(line);
    }
  }
  flushBlock();
  return events;
}

function parseLogEvents(lines) {
  const format = detectLogFormat(lines);
  let events;
  if (format === 'codex') {
    events = parseCodexEvents(lines);
  } else if (format === 'claude') {
    events = parseClaudeEvents(lines);
  } else {
    events = parseRawLogEvents(lines);
  }
  return { format, events };
}

// ================================================================
// STRUCTURED LOG RENDERER
// ================================================================

function toggleEvent(idx) {
  if (expandedEvents.has(idx)) {
    expandedEvents.delete(idx);
  } else {
    expandedEvents.add(idx);
  }
  if (logViewMode === 'flow') {
    renderFlowView();
  } else {
    renderStructuredLogs();
  }
}

function renderEventIcon(type) {
  const icons = {
    message: '<span class="ev-icon ev-icon-message">&#x2759;</span>',
    thinking: '<span class="ev-icon ev-icon-thinking">&#x25CB;</span>',
    command: '<span class="ev-icon ev-icon-command">$</span>',
    error: '<span class="ev-icon ev-icon-error">!</span>',
    info: '<span class="ev-icon ev-icon-info">i</span>',
    system: '<span class="ev-icon ev-icon-system">&#x2022;</span>',
    result: '<span class="ev-icon ev-icon-result">&#x2713;</span>',
  };
  return icons[type] || icons.system;
}

function renderStructuredLogs() {
  const viewer = document.getElementById('log-viewer');
  const empty = document.getElementById('log-empty');
  const sentinel = document.getElementById('log-sentinel');
  const matchCount = document.getElementById('log-match-count');

  if (!parsedLogEvents.length) {
    empty.style.display = 'block';
    matchCount.textContent = '';
    viewer.querySelectorAll('.log-event, .log-line').forEach(el => el.remove());
    return;
  }

  empty.style.display = 'none';
  const filter = logSearchTerm.toLowerCase();
  let matches = 0;
  let html = '';

  for (let i = 0; i < parsedLogEvents.length; i++) {
    const ev = parsedLogEvents[i];
    const content = stripAnsi(ev.content || ev.command || '').toLowerCase();
    const output = stripAnsi(ev.output || '').toLowerCase();
    const isMatch = filter && (content.includes(filter) || output.includes(filter));
    if (filter && !isMatch) continue;
    if (isMatch) matches++;

    const expanded = expandedEvents.has(i);
    const matchClass = isMatch ? ' ev-match' : '';
    const eventKey = `event-${i}`;

    if (ev.type === 'message') {
      html += `<div class="log-event log-event-message${matchClass}">
        ${renderEventIcon('message')}
        <div class="ev-body">
          <div class="ev-content ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-message`)}</div>
        </div>
      </div>`;
    } else if (ev.type === 'thinking') {
      const previewText = stripAnsi(ev.content);
      const preview = previewText.length > 120 ? previewText.substring(0, 120) + '...' : previewText;
      html += `<div class="log-event log-event-thinking${matchClass}">
        <div class="ev-header" onclick="toggleEvent(${i})">
          ${renderEventIcon('thinking')}
          <span class="ev-title">Reasoning</span>
          <span class="ev-preview">${expanded ? '' : escHtml(preview)}</span>
          <span class="ev-chevron">${expanded ? '&#x25BE;' : '&#x25B8;'}</span>
        </div>
        ${expanded ? `<div class="ev-body ev-collapsible">
          <div class="ev-content ev-thinking-text ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-thinking`)}</div>
        </div>` : ''}
      </div>`;
    } else if (ev.type === 'command') {
      const cmdDisplay = ev.command.length > 100 ? ev.command.substring(0, 100) + '...' : ev.command;
      const exitBadge = ev.exitCode !== null
        ? `<span class="ev-badge ${ev.exitCode === 0 ? 'ev-badge-ok' : 'ev-badge-err'}">exit ${ev.exitCode}</span>`
        : '<span class="ev-badge ev-badge-running">running</span>';
      const hasOutput = ev.output && ev.output.trim().length > 0;
      html += `<div class="log-event log-event-command${matchClass}">
        <div class="ev-header${hasOutput ? '' : ' ev-no-toggle'}" ${hasOutput ? `onclick="toggleEvent(${i})"` : ''}>
          ${renderEventIcon('command')}
          <span class="ev-title ev-cmd-text">${escHtml(cmdDisplay)}</span>
          ${exitBadge}
          ${hasOutput ? `<span class="ev-chevron">${expanded ? '&#x25BE;' : '&#x25B8;'}</span>` : ''}
        </div>
        ${expanded && hasOutput ? `<div class="ev-body ev-collapsible">
          <div class="ev-output">${renderLogTextBlock(ev.output, `${eventKey}-output`)}</div>
        </div>` : ''}
      </div>`;
    } else if (ev.type === 'error') {
      html += `<div class="log-event log-event-error${matchClass}">
        ${renderEventIcon('error')}
        <div class="ev-body">
          <div class="ev-content ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-error`)}</div>
        </div>
      </div>`;
    } else if (ev.type === 'info') {
      html += `<div class="log-event log-event-info${matchClass}">
        ${renderEventIcon('info')}
        <div class="ev-body">
          <div class="ev-content ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-info`)}</div>
        </div>
      </div>`;
    } else if (ev.type === 'result') {
      html += `<div class="log-event log-event-result${matchClass}">
        ${renderEventIcon('result')}
        <div class="ev-body">
          <div class="ev-content ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-result`)}</div>
        </div>
      </div>`;
    } else if (ev.type === 'system') {
      html += `<div class="log-event log-event-system${matchClass}">
        ${renderEventIcon('system')}
        <div class="ev-body">
          <div class="ev-content ev-rich-content">${renderLogTextBlock(ev.content, `${eventKey}-system`)}</div>
        </div>
      </div>`;
    }
  }

  // Remove old elements
  viewer.querySelectorAll('.log-event, .log-line').forEach(el => el.remove());
  sentinel.insertAdjacentHTML('beforebegin', html);

  if (filter) {
    matchCount.textContent = matches + ' match' + (matches !== 1 ? 'es' : '');
  } else {
    matchCount.textContent = parsedLogEvents.length + ' events';
  }

  if (autoScroll) {
    requestAnimationFrame(() => { viewer.scrollTop = viewer.scrollHeight; });
  }
}

// ================================================================
// FLOW VIEW (VibeLens-style turn-based timeline)
// ================================================================

const FLOW_TOOL_CATEGORIES = {
  Read: 'file_read', read_file: 'file_read', cat: 'file_read',
  Edit: 'file_write', Write: 'file_write', write_file: 'file_write', NotebookEdit: 'file_write', MultiEdit: 'file_write', apply_patch: 'file_write',
  Glob: 'search', glob: 'search', Grep: 'search', grep: 'search', find: 'search',
  Bash: 'shell', bash: 'shell', shell: 'shell', execute_command: 'shell',
  WebSearch: 'web', WebFetch: 'web', web_search: 'web',
  Agent: 'agent', Task: 'agent', Skill: 'agent',
};

const FLOW_CAT_STYLE = {
  file_read:  { bg: 'rgba(59,130,246,0.18)',  color: '#93c5fd', label: 'read' },
  file_write: { bg: 'rgba(52,211,153,0.18)',  color: '#6ee7b7', label: 'write' },
  shell:      { bg: 'rgba(251,191,36,0.18)',  color: '#fcd34d', label: 'shell' },
  search:     { bg: 'rgba(56,189,248,0.18)',  color: '#7dd3fc', label: 'search' },
  web:        { bg: 'rgba(251,146,60,0.18)',  color: '#fdba74', label: 'web' },
  agent:      { bg: 'rgba(167,139,250,0.18)', color: '#c4b5fd', label: 'agent' },
  other:      { bg: 'rgba(161,161,170,0.12)', color: '#a1a1aa', label: 'other' },
};

function flowGetCatStyle(name) {
  return FLOW_CAT_STYLE[FLOW_TOOL_CATEGORIES[name] || 'other'] || FLOW_CAT_STYLE.other;
}

function extractToolName(command) {
  if (!command) return '';
  if (command.startsWith('patch:')) return 'Edit';
  const first = command.trim().split(/\s+/)[0] || '';
  const map = { cat:'Read', grep:'Grep', rg:'Grep', find:'Glob', ls:'Bash', git:'Bash', sed:'Bash', python:'Bash', python3:'Bash', npm:'Bash', node:'Bash', cd:'Bash' };
  return map[first] || first;
}

// Group flat parsed events into VibeLens-style turns
function buildFlowTurns(events) {
  const turns = [];
  let cur = null;

  function flush() { if (cur) { turns.push(cur); cur = null; } }

  for (let i = 0; i < events.length; i++) {
    const ev = events[i];

    if (ev.type === 'system' || ev.type === 'info' || ev.type === 'result') {
      flush();
      turns.push({ source: ev.type, content: ev.content || '', tools: [], thinking: '', message: '', timestamp: ev.timestamp, firstIdx: i });
      continue;
    }

    if (ev.type === 'thinking') {
      if (!cur) cur = { source: 'agent', message: '', thinking: '', tools: [], timestamp: ev.timestamp, firstIdx: i };
      cur.thinking += (cur.thinking ? '\n' : '') + (ev.content || '');
      continue;
    }

    if (ev.type === 'message') {
      if (!cur) cur = { source: 'agent', message: '', thinking: '', tools: [], timestamp: ev.timestamp, firstIdx: i };
      cur.message += (cur.message ? '\n\n' : '') + (ev.content || '');
      continue;
    }

    if (ev.type === 'command') {
      if (!cur) cur = { source: 'agent', message: '', thinking: '', tools: [], timestamp: ev.timestamp, firstIdx: i };
      cur.tools.push({
        name: ev.toolName || extractToolName(ev.command),
        command: ev.command || '',
        output: ev.output || '',
        exitCode: ev.exitCode,
        evIdx: i,
        timestamp: ev.timestamp,
      });
      continue;
    }
  }
  flush();
  return turns;
}

// Format agent message text with basic markdown: **bold** and `code`
function fvFormatMsg(text) {
  let s = escHtml(text);
  s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
  return s;
}

// Expand/collapse all expandable items in the Flow view
function fvToggleAll() {
  const turns = buildFlowTurns(parsedLogEvents);
  const allIndices = [];
  for (const turn of turns) {
    if (turn.thinking) allIndices.push(turn.firstIdx);
    for (const tool of turn.tools) {
      if (tool.output && tool.output.trim().length > 0) allIndices.push(tool.evIdx);
    }
  }
  const allExpanded = allIndices.length > 0 && allIndices.every(i => expandedEvents.has(i));
  if (allExpanded) {
    expandedEvents.clear();
  } else {
    for (const i of allIndices) expandedEvents.add(i);
  }
  renderFlowView();
}

function renderFlowView() {
  const viewer = document.getElementById('log-viewer');
  const empty = document.getElementById('log-empty');
  const sentinel = document.getElementById('log-sentinel');
  const matchCount = document.getElementById('log-match-count');

  if (!parsedLogEvents.length) {
    empty.style.display = 'block';
    matchCount.textContent = '';
    viewer.querySelectorAll('.log-event, .log-line, .flow-phase, .flow-turn').forEach(el => el.remove());
    return;
  }

  empty.style.display = 'none';
  const filter = logSearchTerm.toLowerCase();
  const turns = buildFlowTurns(parsedLogEvents);

  // Find first timestamp for elapsed time
  let firstTs = null;
  for (const t of turns) { if (t.timestamp) { firstTs = new Date(t.timestamp).getTime(); break; } }

  let html = '';
  let count = 0;
  let prevTs = firstTs;

  for (const turn of turns) {
    const searchText = [turn.message, turn.thinking, turn.content || '', ...(turn.tools || []).map(t => t.command + ' ' + t.output)].join(' ').toLowerCase();
    if (filter && !searchText.includes(filter)) continue;
    count++;

    const ts = turn.timestamp ? new Date(turn.timestamp).getTime() : null;
    const elapsed = (ts && firstTs) ? fmtDurationShort(Math.max(0, (ts - firstTs) / 1000)) : '';
    const gap = (ts && prevTs && count > 1) ? '+' + fmtDurationShort(Math.max(0, (ts - prevTs) / 1000)) : '';
    if (ts) prevTs = ts;

    // System/info — compact
    if (turn.source === 'system' || turn.source === 'info' || turn.source === 'result') {
      html += `<div class="fv-turn">
        <div class="fv-rail"><div class="fv-dot fv-dot-sys"></div><div class="fv-line"></div></div>
        <div class="fv-body"><div class="fv-sys">${escHtml(turn.content)}</div></div>
      </div>`;
      continue;
    }

    // Agent turn
    html += `<div class="fv-turn">`;

    // Left rail: dot + line
    const hasTool = turn.tools.length > 0;
    html += `<div class="fv-rail">
      <div class="fv-dot ${hasTool ? 'fv-dot-tool' : 'fv-dot-msg'}"></div>
      <div class="fv-line"></div>
    </div>`;

    html += `<div class="fv-body">`;

    // Time row
    if (elapsed || gap) {
      html += `<div class="fv-time">${elapsed ? `<span class="fv-time-el">${elapsed}</span>` : ''}${gap ? `<span class="fv-time-gap">${gap}</span>` : ''}</div>`;
    }

    // Thinking (collapsible)
    if (turn.thinking) {
      const idx = turn.firstIdx;
      const exp = expandedEvents.has(idx);
      const preview = turn.thinking.length > 140 ? turn.thinking.substring(0, 140) + '...' : turn.thinking;
      html += `<div class="fv-thinking" onclick="toggleEvent(${idx})">
        <div class="fv-thinking-hd">
          <span class="fv-thinking-ico">&#x2727;</span>
          <span>Reasoning</span>
          <span class="fv-chev">${exp ? '&#x25BE;' : '&#x25B8;'}</span>
        </div>
        ${exp
          ? `<div class="fv-thinking-body">${escHtml(turn.thinking)}</div>`
          : `<div class="fv-thinking-pre">${escHtml(preview)}</div>`}
      </div>`;
    }

    // Agent message
    if (turn.message) {
      const txt = turn.message.length > 400 ? turn.message.substring(0, 400) + '...' : turn.message;
      html += `<div class="fv-msg">
        <div class="fv-msg-ico">&#x25C6;</div>
        <div class="fv-msg-text">${fvFormatMsg(txt)}</div>
      </div>`;
    }

    // Tool calls
    if (turn.tools.length > 0) {
      // Category summary
      const cats = {};
      for (const t of turn.tools) {
        const c = FLOW_TOOL_CATEGORIES[t.name] || 'other';
        cats[c] = (cats[c] || 0) + 1;
      }
      const catHtml = Object.entries(cats).map(([c, n]) => {
        const s = FLOW_CAT_STYLE[c] || FLOW_CAT_STYLE.other;
        return `<span class="fv-cat" style="color:${s.color}"><span class="fv-cat-dot" style="background:${s.color}"></span>${s.label} ${n}</span>`;
      }).join('');

      html += `<div class="fv-tools">
        <div class="fv-tools-hd">
          <span class="fv-tools-n">${turn.tools.length} tool${turn.tools.length > 1 ? 's' : ''}</span>
          <span class="fv-tools-sep">·</span>${catHtml}
        </div>
        <div class="fv-tools-list">`;

      for (const tool of turn.tools) {
        const cs = flowGetCatStyle(tool.name);
        const exp = expandedEvents.has(tool.evIdx);
        const hasOut = tool.output && tool.output.trim().length > 0;
        // Smart preview: extract the most useful part of the command
        let cmdPre = tool.command;
        // For file paths, show just the filename
        const pathMatch = cmdPre.match(/\/([^\/\s]+\.[a-z]{1,6})(?:\s|$)/i);
        if (pathMatch && cmdPre.length > 60) cmdPre = pathMatch[1];
        else if (cmdPre.length > 60) cmdPre = cmdPre.substring(0, 57) + '...';
        const isError = tool.exitCode !== null && tool.exitCode !== 0;
        const errBadge = isError ? '<span class="fv-pill-err">ERR</span>' : '';
        const outLines = hasOut ? stripAnsi(tool.output).trim().split('\n').length : 0;
        const outHint = hasOut && !exp ? `<span class="fv-pill-lines">${outLines} line${outLines !== 1 ? 's' : ''}</span>` : '';

        html += `<div class="fv-pill${exp ? ' open' : ''}${isError ? ' fv-pill-error' : ''}" style="--pc:${cs.color};--pb:${cs.bg}">
          <div class="fv-pill-hd" onclick="toggleEvent(${tool.evIdx})">
            <span class="fv-pill-name">${escHtml(tool.name)}</span>
            <span class="fv-pill-cmd">${escHtml(cmdPre)}</span>
            ${errBadge}${outHint}
            <span class="fv-chev">${exp ? '&#x25BE;' : '&#x25B8;'}</span>
          </div>
          ${exp && hasOut ? `<div class="fv-pill-out"><pre>${escHtml(stripAnsi(tool.output).substring(0, 3000))}</pre></div>` : ''}
        </div>`;
      }
      html += `</div></div>`;
    }

    html += `</div></div>`;
  }

  // Update expand/collapse all button
  const expandBtn = document.getElementById('fv-expand-all-btn');
  if (expandBtn) {
    expandBtn.style.display = count > 0 ? '' : 'none';
    expandBtn.textContent = expandedEvents.size > 0 ? 'Collapse all' : 'Expand all';
  }

  // Preserve scroll position on re-render (for expand/collapse)
  const scrollTop = viewer.scrollTop;
  viewer.querySelectorAll('.log-event, .log-line, .flow-phase, .flow-turn, .fv-turn').forEach(el => el.remove());
  sentinel.insertAdjacentHTML('beforebegin', html);
  matchCount.textContent = count + ' turns';

  if (autoScroll) {
    requestAnimationFrame(() => { viewer.scrollTop = viewer.scrollHeight; });
  } else {
    requestAnimationFrame(() => { viewer.scrollTop = scrollTop; });
  }
}

function setLogViewMode(mode) {
  logViewMode = mode;
  document.querySelectorAll('.log-view-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.mode === mode);
  });
  const expandBtn = document.getElementById('fv-expand-all-btn');
  if (expandBtn) expandBtn.style.display = mode === 'flow' ? '' : 'none';
  renderLogs();
}

// ================================================================
// LOG VIEWER
// ================================================================

let logObserver = null;

function setupScrollObserver() {
  const viewer = document.getElementById('log-viewer');
  const sentinel = document.getElementById('log-sentinel');
  const btn = document.getElementById('scroll-bottom-btn');

  if (logObserver) logObserver.disconnect();

  logObserver = new IntersectionObserver(
    (entries) => {
      const isVisible = entries[0].isIntersecting;
      autoScroll = isVisible;
      btn.classList.toggle('visible', !isVisible && logLines.length > 0);
    },
    { root: viewer, threshold: 0 }
  );

  logObserver.observe(sentinel);
}

function scrollToBottom() {
  const viewer = document.getElementById('log-viewer');
  viewer.scrollTop = viewer.scrollHeight;
  autoScroll = true;
  document.getElementById('scroll-bottom-btn').classList.remove('visible');
}

function renderLogs() {
  // Parse events if needed
  if (logLines.length > 0 && parsedLogEvents.length === 0) {
    const parsed = parseLogEvents(logLines);
    parsedLogEvents = parsed.events;
    // If we got structured events, default to structured view
    // If raw/unparseable, stay in raw
    if (parsed.format === 'raw' && parsedLogEvents.length > 0) {
      // For raw logs with detected events, still offer structured
    }
  }

  if (logViewMode === 'flow' && parsedLogEvents.length > 0) {
    renderFlowView();
    return;
  }

  if (logViewMode === 'structured' && parsedLogEvents.length > 0) {
    renderStructuredLogs();
    return;
  }

  // Raw view (original behavior)
  renderRawLogs();
}

function renderRawLogs() {
  const viewer = document.getElementById('log-viewer');
  const empty = document.getElementById('log-empty');
  const sentinel = document.getElementById('log-sentinel');
  const matchCount = document.getElementById('log-match-count');

  if (!logLines.length) {
    empty.style.display = 'block';
    matchCount.textContent = '';
    viewer.querySelectorAll('.log-line, .log-event').forEach(el => el.remove());
    return;
  }

  empty.style.display = 'none';

  const filter = logSearchTerm.toLowerCase();
  let matches = 0;

  let html = '';
  for (let i = 0; i < logLines.length; i++) {
    const line = logLines[i];
    const plainLine = stripAnsi(line);
    const isMatch = filter && plainLine.toLowerCase().includes(filter);
    if (isMatch) matches++;
    const cls = isMatch ? ' match' : '';
    html += `<div class="log-line${cls}"><div class="log-line-num">${i + 1}</div><div class="log-line-text">${renderLogLineContent(line, i)}</div></div>`;
  }

  // Remove old elements
  viewer.querySelectorAll('.log-line, .log-event').forEach(el => el.remove());

  // Insert before sentinel
  sentinel.insertAdjacentHTML('beforebegin', html);

  if (filter) {
    matchCount.textContent = matches + ' match' + (matches !== 1 ? 'es' : '');
  } else {
    matchCount.textContent = logLines.length + ' lines';
  }

  if (autoScroll) {
    requestAnimationFrame(() => {
      viewer.scrollTop = viewer.scrollHeight;
    });
  }
}

function filterLogs() {
  logSearchTerm = document.getElementById('log-search').value;
  renderLogs();
}

async function fetchLogs(taskId) {
  try {
    const r = await fetch(`/tasks/${taskId}/logs?tail=500`);
    if (!r.ok) return;
    const data = await r.json();
    const content = data.logs || '';
    const newLines = content ? content.split('\n') : [];
    // Re-parse only if content changed
    if (newLines.length !== logLines.length || newLines.join('\n') !== logLines.join('\n')) {
      logLines = newLines;
      parsedLogEvents = [];
      expandedEvents.clear();
    }
    renderLogs();
  } catch (e) {
    console.error('Log fetch failed:', e);
  }
}

function startLogPolling() {
  stopLogPolling();

  if (!selectedTaskId) return;
  if (!selectedTask || selectedTask.status !== 'running') return;

  // Fetch immediately
  fetchLogs(selectedTaskId);

  // Poll every 2s
  logTimer = setInterval(() => {
    if (selectedTaskId && selectedTask && selectedTask.status === 'running') {
      fetchLogs(selectedTaskId);
    } else {
      stopLogPolling();
    }
  }, 2000);
}

function stopLogPolling() {
  if (logTimer) {
    clearInterval(logTimer);
    logTimer = null;
  }
}

// ================================================================
// LIVE ACTIVITY (detail-status)
// ================================================================

async function fetchDetailStatus(taskId) {
  try {
    const r = await fetch(`/tasks/${taskId}/detail-status`);
    if (!r.ok) return;
    detailStatusData = await r.json();
    renderLiveActivity();
  } catch (e) {
    console.error('Detail-status fetch failed:', e);
  }
}

function startDetailStatusPolling() {
  stopDetailStatusPolling();

  if (!selectedTaskId) return;
  if (!selectedTask || selectedTask.status !== 'running') {
    hideLiveActivity();
    return;
  }

  // Fetch immediately
  fetchDetailStatus(selectedTaskId);

  // Poll every 5s
  detailStatusTimer = setInterval(() => {
    if (selectedTaskId && selectedTask && selectedTask.status === 'running') {
      fetchDetailStatus(selectedTaskId);
    } else {
      stopDetailStatusPolling();
      hideLiveActivity();
    }
  }, 5000);
}

function stopDetailStatusPolling() {
  if (detailStatusTimer) {
    clearInterval(detailStatusTimer);
    detailStatusTimer = null;
  }
}

function hideLiveActivity() {
  const panel = document.getElementById('live-activity-panel');
  if (panel) panel.classList.remove('visible');
}

function renderLiveActivity() {
  const panel = document.getElementById('live-activity-panel');
  if (!panel) return;

  if (!detailStatusData || !selectedTask || selectedTask.status !== 'running') {
    panel.classList.remove('visible');
    return;
  }

  panel.classList.add('visible');
  const d = detailStatusData;

  // Current action
  const actionEl = document.getElementById('la-current-action');
  actionEl.textContent = d.current_activity || '—';
  actionEl.title = d.current_activity || '';

  // Files touched
  const filesEl = document.getElementById('la-files-list');
  const files = d.files_touched || [];
  if (files.length === 0) {
    filesEl.innerHTML = '<span style="color:var(--text-dim);font-size:10px">None yet</span>';
  } else {
    filesEl.innerHTML = files.slice(0, 12).map(f => {
      const short = f.split('/').pop();
      return `<span class="la-file-chip" title="${escHtml(f)}">${escHtml(short)}</span>`;
    }).join('') + (files.length > 12 ? `<span class="la-file-chip">+${files.length - 12}</span>` : '');
  }

  // Tool usage bars
  const toolsEl = document.getElementById('la-tool-bars');
  const tools = d.tools_used || {};
  const toolEntries = Object.entries(tools).sort((a, b) => b[1] - a[1]);
  if (toolEntries.length === 0) {
    toolsEl.innerHTML = '<span style="color:var(--text-dim);font-size:10px">None yet</span>';
  } else {
    const maxCount = Math.max(...toolEntries.map(e => e[1]), 1);
    toolsEl.innerHTML = toolEntries.slice(0, 8).map(([name, count]) => {
      const pct = Math.max((count / maxCount) * 100, 4);
      return `<div class="la-tool-row">
        <span class="la-tool-name">${escHtml(name)}</span>
        <div class="la-tool-bar" style="width:${pct}%"></div>
        <span class="la-tool-count">${count}</span>
      </div>`;
    }).join('');
  }
}

// ================================================================
// DETAILS TAB
// ================================================================

function renderDetailsTab() {
  const container = document.getElementById('details-content');
  if (!selectedTask) {
    container.innerHTML = '<div style="color:var(--text-dim);padding:24px">No task selected</div>';
    return;
  }

  const t = selectedTask;
  const r = selectedResult;
  const startIso = (r && r.started_at) || t.started_at || t.created_at;
  const runtime = r && r.completed_at ? durationBetween(startIso, r.completed_at) : null;

  let html = '';

  // Task info
  html += `<div class="details-section">
    <div class="details-section-title">Task Info</div>
    <div class="kv-grid">
      <div class="kv-key">ID</div><div class="kv-val">${escHtml(t.id)}</div>
      <div class="kv-key">Project</div><div class="kv-val">${escHtml(t.project)}</div>
      <div class="kv-key">Status</div><div class="kv-val">${escHtml(t.status)}</div>
      <div class="kv-key">Priority</div><div class="kv-val">${escHtml(t.priority)}</div>
      <div class="kv-key">Mode</div><div class="kv-val">${escHtml(t.mode)}</div>
      <div class="kv-key">Backend</div><div class="kv-val">${escHtml(t.backend || 'claude')}</div>
      <div class="kv-key">Created</div><div class="kv-val">${fmtDate(t.created_at)}</div>
      ${t.started_at ? `<div class="kv-key">Started</div><div class="kv-val">${fmtDate(startIso)}</div>` : ''}
      ${r && r.completed_at ? `<div class="kv-key">Completed</div><div class="kv-val">${fmtDate(r.completed_at)}</div>` : ''}
      ${runtime ? `<div class="kv-key">Runtime</div><div class="kv-val">${runtime}</div>` : ''}
      ${t.infra ? `<div class="kv-key">Infra</div><div class="kv-val">${escHtml(t.infra)}</div>` : ''}
      ${t.timeout ? `<div class="kv-key">Timeout</div><div class="kv-val">${escHtml(fmtDurationShort(t.timeout))}</div>` : ''}
      ${t.last_run_at ? `<div class="kv-key">Last run</div><div class="kv-val">${fmtDate(t.last_run_at)}</div>` : ''}
      ${t.session_id ? `<div class="kv-key">Session</div><div class="kv-val">${escHtml(t.session_id)}</div>` : ''}
      <div class="kv-key">Checkpoint</div><div class="kv-val">${t.checkpoint ? 'yes' : 'no'}</div>
    </div>
  </div>`;

  // Description
  html += `<div class="details-section">
    <div class="details-section-title">Description</div>
    <div style="font-size:12px;color:var(--text-muted);line-height:1.6">${escHtml(t.description)}</div>
  </div>`;

  // Exit criteria
  if (t.exit_criteria) {
    html += `<div class="details-section">
      <div class="details-section-title">Exit Criteria</div>
      <div style="font-size:12px;color:var(--text-muted);line-height:1.6">${escHtml(t.exit_criteria)}</div>
    </div>`;
  }

  // Schedule
  if (t.schedule && t.schedule.type !== 'once') {
    html += `<div class="details-section">
      <div class="details-section-title">Schedule</div>
      <div class="kv-grid">
        <div class="kv-key">Type</div><div class="kv-val">${escHtml(t.schedule.type)}</div>
        ${t.schedule.expr ? `<div class="kv-key">Expression</div><div class="kv-val">${escHtml(t.schedule.expr)}</div>` : ''}
        ${t.schedule.every ? `<div class="kv-key">Every</div><div class="kv-val">${escHtml(t.schedule.every)}</div>` : ''}
        ${t.schedule.until ? `<div class="kv-key">Until</div><div class="kv-val">${escHtml(t.schedule.until)}</div>` : ''}
        ${t.schedule.depends_on ? `<div class="kv-key">Depends on</div><div class="kv-val">${escHtml(t.schedule.depends_on)}</div>` : ''}
      </div>
    </div>`;
  }

  // Result
  if (r) {
    // Summary
    if (r.summary) {
      html += `<div class="details-section">
        <div class="details-section-title">Summary</div>
        <div style="font-size:12px;color:var(--text-muted);line-height:1.6">${escHtml(r.summary)}</div>
      </div>`;
    }

    // Metrics
    const metricKeys = Object.keys(r.metrics || {});
    if (metricKeys.length) {
      html += `<div class="details-section">
        <div class="details-section-title">Metrics</div>
        <div class="kv-grid">
          ${metricKeys.map(k => `<div class="kv-key">${escHtml(k)}</div><div class="kv-val">${escHtml(String(r.metrics[k]))}</div>`).join('')}
        </div>
      </div>`;
    }

    // Cost breakdown
    if (r.cost) {
      const costVal = r.cost.cost_usd || 0;
      html += `<div class="details-section">
        <div class="details-section-title">Cost</div>
        <div class="kv-grid">
          <div class="kv-key">Input tokens</div><div class="kv-val">${(r.cost.input_tokens || 0).toLocaleString()}</div>
          <div class="kv-key">Output tokens</div><div class="kv-val">${(r.cost.output_tokens || 0).toLocaleString()}</div>
          <div class="kv-key">Total tokens</div><div class="kv-val">${(r.cost.total_tokens || 0).toLocaleString()}</div>
          <div class="kv-key">Cost (USD)</div><div class="kv-val">${fmtCost(costVal)}</div>
        </div>
        ${costVal > 0 ? `<div class="cost-subscription-note" style="margin-top:8px">
          <span class="sub-note-icon">&#x1F4B3;</span> Equivalent API cost: <strong>${fmtCost(costVal)}</strong> (covered by subscription)
        </div>` : ''}
      </div>`;
    }

    // Artifacts
    if (r.artifacts && r.artifacts.length) {
      html += `<div class="details-section">
        <div class="details-section-title">Artifacts</div>
        ${r.artifacts.map(a => `<div class="artifact-block">${escHtml(JSON.stringify(a, null, 2))}</div>`).join('')}
      </div>`;
    }
  }

  container.innerHTML = html;
}

// ================================================================
// TIMELINE TAB — Enhanced with agent log parsing
// ================================================================

function parseAgentLogEvents(logText) {
  if (!logText) return [];
  const lines = logText.split('\n');
  const events = [];
  const seen = new Set(); // deduplicate similar events

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line) continue;

    let event = null;

    // Subagent / Agent tool
    if (/agent\s*tool|subagent/i.test(line)) {
      event = { label: 'Spawned subagent', color: 'purple', icon: 'agent' };
    }
    // Edit / Write tool calls
    else if (/\bEdit\b|\bWrite\b/i.test(line) && /tool/i.test(line)) {
      const fileMatch = line.match(/["']([^"']+\.\w+)["']/);
      const fname = fileMatch ? fileMatch[1].split('/').pop() : '';
      const desc = fname ? `Edited file: ${fname}` : 'Edited file';
      event = { label: desc, color: 'cyan', icon: 'edit' };
    }
    // Bash tool
    else if (/\bBash\b/i.test(line) && /tool/i.test(line)) {
      event = { label: 'Ran command', color: 'blue', icon: 'bash' };
    }
    // Commit
    else if (/\bcommit\b/i.test(line) && (/git/i.test(line) || /made/i.test(line))) {
      event = { label: 'Made commit', color: 'green', icon: 'commit' };
    }
    // Tests
    else if (/\bPASS\b/.test(line)) {
      event = { label: 'Tests: PASS', color: 'green', icon: 'test' };
    }
    else if (/\bFAIL\b/.test(line)) {
      event = { label: 'Tests: FAIL', color: 'red', icon: 'test' };
    }
    // Errors
    else if (/\berror\b/i.test(line) && !/\bno error/i.test(line)) {
      event = { label: 'Error encountered', color: 'red', icon: 'error' };
    }

    if (event) {
      // Deduplicate: skip if we saw the exact same label recently (within 5 lines)
      const key = event.label;
      if (!seen.has(key) || events.length === 0 || (i - (events[events.length - 1]._lineIdx || 0)) > 5) {
        seen.add(key);
        event._lineIdx = i;
        event.lineNum = i + 1;
        events.push(event);
      }
    }
  }

  // Limit to 20 most interesting events spread across the log
  if (events.length > 20) {
    const step = events.length / 20;
    const sampled = [];
    for (let i = 0; i < 20; i++) {
      sampled.push(events[Math.floor(i * step)]);
    }
    return sampled;
  }

  return events;
}

function renderTimelineTab() {
  const container = document.getElementById('timeline-content');
  if (!selectedTask) {
    container.innerHTML = '';
    return;
  }

  const t = selectedTask;
  const r = selectedResult;

  let points = [];

  // Created
  points.push({
    label: 'Created',
    time: t.created_at,
    color: 'blue',
    major: true,
  });

  // Running
  if (['running', 'completed', 'failed', 'cancelled'].includes(t.status)) {
    const runStart = t.started_at || (r && r.started_at) || t.created_at;
    points.push({
      label: 'Running',
      time: runStart,
      color: 'amber',
      major: true,
    });
  }

  // Parse agent log events and insert them
  if (logLines.length > 0) {
    const logText = logLines.join('\n');
    const agentEvents = parseAgentLogEvents(logText);
    for (const ev of agentEvents) {
      points.push({
        label: ev.label,
        time: null, // we often can't extract exact time from log lines
        color: ev.color,
        major: false,
        lineRef: ev.lineNum,
      });
    }
  }

  // Completed/Failed
  if (r && r.completed_at) {
    const label = r.status === 'failed' ? 'Failed' : (r.status === 'cancelled' ? 'Cancelled' : 'Completed');
    const color = r.status === 'failed' ? 'red' : (r.status === 'cancelled' ? 'dim' : 'green');
    points.push({
      label: label,
      time: r.completed_at,
      color: color,
      major: true,
    });
  } else if (t.status === 'running') {
    points.push({
      label: 'In progress...',
      time: null,
      color: 'amber',
      major: true,
    });
  }

  let html = '<div class="timeline">';
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    const dur = (i > 0 && points[i - 1].time && p.time) ? durationBetween(points[i - 1].time, p.time) : null;
    const dotClass = p.major ? p.color : p.color + ' small';
    const labelClass = p.major ? '' : ' event-label';

    html += `
      <div class="timeline-point">
        <div class="timeline-dot ${dotClass}"></div>
        <div class="timeline-label${labelClass}">${escHtml(p.label)}</div>
        ${p.time ? `<div class="timeline-time">${fmtDate(p.time)}</div>` : (p.lineRef ? `<div class="timeline-time" style="color:var(--text-dim)">line ${p.lineRef}</div>` : '<div class="timeline-time">now</div>')}
        ${dur ? `<div class="timeline-duration">${dur} since previous</div>` : ''}
      </div>
    `;
  }
  html += '</div>';

  container.innerHTML = html;
}

// ================================================================
// DATA FETCHING
// ================================================================

async function fetchTaskDetail(taskId) {
  // Fetch task detail and result in parallel
  const [taskRes, resultRes, logsRes] = await Promise.allSettled([
    fetchJson(`/tasks/${taskId}`),
    fetch(`/results/${taskId}`).then(r => r.ok ? r.json() : null),
    fetch(`/tasks/${taskId}/logs?tail=500`).then(r => r.ok ? r.json() : null),
  ]);

  if (taskRes.status === 'fulfilled' && taskRes.value) {
    selectedTask = taskRes.value;
  }

  if (resultRes.status === 'fulfilled' && resultRes.value) {
    selectedResult = resultRes.value;
  }

  if (logsRes.status === 'fulfilled' && logsRes.value) {
    const content = logsRes.value.logs || '';
    logLines = content ? content.split('\n') : [];
    parsedLogEvents = [];
    expandedEvents.clear();
  }

  // Re-render
  renderDetailTopbar();

  // Render active tab
  const activeTab = document.querySelector('.detail-tab.active');
  if (activeTab) {
    const tabName = activeTab.dataset.tab;
    if (tabName === 'logs') renderLogs();
    if (tabName === 'details') renderDetailsTab();
    if (tabName === 'timeline') renderTimelineTab();
  }

  setupScrollObserver();
}

async function refreshAll() {
  const [statsRes, tasksRes, resultsRes, projectsRes, batchesRes, spokesRes] = await Promise.allSettled([
    fetchJson('/stats'),
    fetchJson('/tasks'),
    fetchJson('/results?last_n=50'),
    fetchJson('/projects'),
    fetchJson('/batches'),
    fetchJson('/spokes'),
  ]);

  refreshMeta.loading = false;
  refreshMeta.connected = false;

  if (statsRes.status === 'fulfilled') statsData = statsRes.value;
  if (tasksRes.status === 'fulfilled') {
    const tv = tasksRes.value;
    allTasks = Array.isArray(tv) ? tv : (tv && tv.items ? tv.items : []);
  }
  if (resultsRes.status === 'fulfilled') {
    const rv = resultsRes.value;
    allResults = Array.isArray(rv) ? rv : (rv && rv.items ? rv.items : []);
  }
  if (projectsRes.status === 'fulfilled') {
    const pv = projectsRes.value;
    allProjects = Array.isArray(pv) ? pv : (pv && pv.items ? pv.items : []);
  }
  if (batchesRes.status === 'fulfilled') allBatches = batchesRes.value || [];
  if (spokesRes.status === 'fulfilled') allSpokes = Array.isArray(spokesRes.value) ? spokesRes.value : [];

  const failures = [statsRes, tasksRes, resultsRes, projectsRes].filter(r => r.status !== 'fulfilled');
  if (failures.length) {
    const firstError = failures[0].reason instanceof Error ? failures[0].reason.message : String(failures[0].reason || 'unknown error');
    refreshMeta.error = firstError;
  } else {
    refreshMeta.connected = true;
    refreshMeta.error = '';
    refreshMeta.lastUpdated = new Date().toISOString();
  }

  // Update selected task if still in list
  if (selectedTaskId) {
    const updated = allTasks.find(t => t.id === selectedTaskId);
    if (updated) {
      selectedTask = updated;
      const updatedResult = resultForTask(selectedTaskId);
      if (updatedResult) selectedResult = updatedResult;
    } else {
      selectedTaskId = null;
      selectedTask = null;
      selectedResult = null;
    }
  }

  renderMainHeader();
  renderStatusBanner();
  renderToolbar();
  renderQueueBar(statsData);
  renderSidebarSpokes();
  renderTaskList(allTasks);
  renderSidebarFooter();

  if (selectedTaskId) {
    renderDetailTopbar();
    // Refresh details tab if visible
    const activeTab = document.querySelector('.detail-tab.active');
    if (activeTab && activeTab.dataset.tab === 'details') renderDetailsTab();
    if (activeTab && activeTab.dataset.tab === 'timeline') renderTimelineTab();

    // Check if task stopped running
    if (selectedTask && selectedTask.status !== 'running') {
      stopLogPolling();
      stopDetailStatusPolling();
      hideLiveActivity();
      // Fetch final result if not loaded
      if (!selectedResult) {
        try {
          selectedResult = await fetchJson(`/results/${selectedTaskId}`);
        } catch (e) {}
      }
    }
  } else {
    renderOverview();
  }

  // Fetch scheduled items (non-blocking, updates section when ready)
  fetchScheduledItems();
}

// ================================================================
// INIT
// ================================================================

(async function init() {
  projectGroupState = loadStoredMap(PROJECT_GROUP_STORAGE_KEY);
  projectGroupHistoryState = loadStoredMap(PROJECT_GROUP_HISTORY_STORAGE_KEY);
  usageExpanded = loadStoredBoolean(USAGE_SECTION_STORAGE_KEY, true);
  restoreThemePreference();
  renderStaticIcons();
  setupSidebarResize();
  syncSidebarForViewport();
  await refreshAll();

  // Fetch usage on load and refresh every 60s
  fetchUtilization();
  utilizationTimer = setInterval(fetchUtilization, 300000); // 5 min — Anthropic rate limits

  // Try SSE for live updates; fall back to polling
  let sseConnected = false;
  try {
    const evtSource = new EventSource('/events');
    evtSource.addEventListener('task_status', (e) => {
      refreshAll();
    });
    evtSource.addEventListener('log_line', (e) => {
      if (selectedTaskId) {
        const data = JSON.parse(e.data);
        if (data.task_id === selectedTaskId) {
          refreshAll();
        }
      }
    });
    evtSource.onopen = () => { sseConnected = true; };
    evtSource.onerror = () => {
      if (!sseConnected) {
        evtSource.close();
        // Fall back to polling
        taskListTimer = setInterval(refreshAll, 5000);
      }
    };
  } catch (err) {
    // SSE not supported or unavailable — fall back to polling
    taskListTimer = setInterval(refreshAll, 5000);
  }

  // Always poll as backup (SSE may drop)
  if (!sseConnected) {
    taskListTimer = setInterval(refreshAll, 5000);
  }

  // Setup scroll observer
  setupScrollObserver();

  window.addEventListener('resize', () => {
    syncSidebarForViewport();
  });
})();
