From e5d97990182482c7185f23a1589e6fa3767161ca Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 28 Jul 2026 21:20:52 +0800 Subject: [PATCH] feat(ui): GPU + Live Agents dashboard, dark theme - drop quota / repos / headless-task panels; page is now GPU board + Live Agents (frontend only, headless task API still available) - Live Agents: grouped by tmux session, launch form (engine/session/cwd/ window name), open-terminal link per agent - compact GPU board: multi-column host cards, fixed 4-per-row GPU thumbs with status glow, expanded host spans full row - permanent dark theme tuned for a monitoring dashboard; terminal-styled session group headers; refresh now polls three light endpoints instead of the full dashboard aggregate Co-Authored-By: Claude Fable 5 --- public/app.js | 443 +++++++--------------------------------------- public/index.html | 133 ++++++-------- public/styles.css | 281 ++++++++++++++++++++++------- 3 files changed, 334 insertions(+), 523 deletions(-) diff --git a/public/app.js b/public/app.js index a7a6232..98afdcb 100644 --- a/public/app.js +++ b/public/app.js @@ -1,15 +1,7 @@ let state = { gpus: [], - quotas: [], - repos: [], - branches: [], - agentProfiles: [], - tasks: [], - selectedRepoId: null, - selectedBranch: null, + liveAgents: { ok: false, agents: [] }, expandedGpuHosts: new Set(), - expandedTasks: new Set(), - expandedTerminals: new Set(), settings: { gpuHosts: [] }, errors: {} }; @@ -31,47 +23,6 @@ function escapeHtml(value) { }[char])); } -function formatDate(value) { - if (!value) return 'N/A'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return 'N/A'; - return date.toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); -} - -function formatDuration(start, end) { - const from = new Date(start).getTime(); - const to = new Date(end).getTime(); - if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return ''; - const seconds = Math.max(1, Math.round((to - from) / 1000)); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - return rest ? `${hours}h ${rest}m` : `${hours}h`; -} - -function statusLabel(status) { - return { - running: '运行中', - completed: '已完成', - failed: '失败' - }[status] || status || '未知'; -} - -function canResumeTask(task) { - return Boolean(task.sessionId && task.status !== 'running'); -} - -function shortSession(sessionId) { - return sessionId ? sessionId.slice(0, 8) : '无 Session'; -} - function isGpuIdle(gpu) { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5; @@ -113,16 +64,13 @@ async function request(path, options) { const ERROR_LABELS = { settings: '设置', gpus: 'GPU', - quotas: '额度', - repos: '项目列表', - agentProfiles: 'Agent 配置', - tasks: '任务历史' + liveAgents: 'Live Agents' }; function renderErrorBanner() { const banner = $('errorBanner'); if (!banner) return; - const entries = Object.entries(state.errors || {}); + const entries = Object.entries(state.errors || {}).filter(([key]) => ERROR_LABELS[key]); if (!entries.length) { banner.hidden = true; banner.innerHTML = ''; @@ -208,246 +156,44 @@ function renderGpus() { }).join('') : '
还没有配置 GPU 机器
'; } -function renderQuotas() { - const okSources = state.quotas.filter((quota) => quota.ok).length; - $('quotaSummary').textContent = state.quotas.length ? `${okSources}/${state.quotas.length} 可用` : '未配置'; - $('quotaBoard').innerHTML = state.quotas.length ? state.quotas.map((quota) => { - const summary = quota.summary || {}; - const limit = summary.limit ?? null; - const used = summary.used ?? null; - const remaining = summary.remaining ?? null; - const usedPercent = limit && used !== null ? percent(used, limit) : null; - return `
-
- ${escapeHtml(quota.label)} - ${quota.ok ? '已更新' : '未配置'} +function renderLiveAgents() { + const agents = state.liveAgents?.agents || []; + $('liveAgentSummary').textContent = agents.length ? `${agents.length} 个运行中` : '无运行中'; + if (!agents.length) { + $('liveAgentBoard').innerHTML = '
tmux 中没有正在运行的 agent
'; + return; + } + const groups = new Map(); + for (const agent of agents) { + if (!groups.has(agent.session)) groups.set(agent.session, []); + groups.get(agent.session).push(agent); + } + $('liveAgentBoard').innerHTML = [...groups.entries()].map(([session, members]) => { + const rows = members.map((agent) => { + const termUrl = `/term/?arg=${encodeURIComponent(agent.session)}&arg=${encodeURIComponent(agent.windowIndex)}`; + return `
+ ${escapeHtml(agent.engine)} + + :${agent.windowIndex} + ${escapeHtml(agent.windowName)} + + ${escapeHtml(agent.cwd)} + 打开终端 +
`; + }).join(''); + return `
+
+ ${escapeHtml(session)}
- ${quota.ok ? ` -
剩余${remaining ?? 'N/A'}
-
已用${used ?? 'N/A'}
- ${usedPercent === null ? '' : ``} - ` : `
${escapeHtml(quota.error || '尚未配置')}
`} -
`; - }).join('') : '
还没有配置额度数据源
'; -} - -function renderRepos() { - const selectedRepo = state.repos.find((repo) => repo.id === state.selectedRepoId); - $('repoSummary').textContent = state.repos.length - ? (selectedRepo ? `${state.repos.length} 个 · 当前 ${selectedRepo.fullName}` : `${state.repos.length} 个`) - : '暂无'; - $('repoBoard').innerHTML = state.repos.length ? state.repos.map((repo) => { - const selected = repo.id === state.selectedRepoId ? ' selected' : ''; - return `
-
- ${escapeHtml(repo.fullName)} - ${escapeHtml(repo.defaultBranch)} -
- ${repo.description ? `
${escapeHtml(repo.description)}
` : ''} -
${escapeHtml(repo.cloneUrl)}
-
`; - }).join('') : '
无法读取项目,请检查 Gitea 配置
'; - renderAgentContext(); -} - -function renderAgentContext() { - const repo = state.repos.find((item) => item.id === state.selectedRepoId); - const repoEl = $('agentContextRepo'); - const branchEl = $('agentContextBranch'); - if (!repoEl || !branchEl) return; - if (repo) { - repoEl.textContent = repo.fullName; - repoEl.classList.remove('empty'); - } else { - repoEl.textContent = '从左侧选择一个项目'; - repoEl.classList.add('empty'); - } - if (state.selectedBranch) { - branchEl.textContent = state.selectedBranch; - branchEl.hidden = false; - } else { - branchEl.hidden = true; - branchEl.textContent = ''; - } -} - -function renderBranches() { - $('branchSelect').innerHTML = state.branches.length ? state.branches.map((branch) => { - const selected = branch.name === state.selectedBranch ? 'selected' : ''; - return ``; - }).join('') : ''; - renderAgentContext(); -} - -function renderProfiles() { - $('profileSelect').innerHTML = state.agentProfiles.map((profile) => ( - `` - )).join(''); -} - -function taskMatchesSelection(task) { - return task.repoFullName === state.selectedRepoId && task.branch === state.selectedBranch && canResumeTask(task); -} - -function renderResumeTasks() { - const options = state.tasks.filter(taskMatchesSelection); - const prev = $('resumeTaskSelect').value; - $('resumeTaskSelect').innerHTML = [ - '', - ...options.map((task) => { - const prompt = task.prompt ? ` · ${task.prompt.slice(0, 40)}` : ''; - const label = `${formatDate(task.finishedAt || task.createdAt)} · ${statusLabel(task.status)} · ${shortSession(task.sessionId)}${prompt}`; - return ``; - }) - ].join(''); - if (prev && options.some((task) => task.id === prev)) { - $('resumeTaskSelect').value = prev; - } - $('resumeTaskButton').disabled = options.length === 0; -} - -function renderTerminal(label, content, tone = '', expanded = true, terminalKey = '') { - if (!content) return ''; - const lineCount = content.split('\n').length; - const body = `
${escapeHtml(content)}
`; - if (!expanded) { - const openAttr = terminalKey && state.expandedTerminals.has(terminalKey) ? ' open' : ''; - return `
- - ${label} - ${lineCount} lines - - ${body} -
`; - } - return `
-
- ${label} - ${lineCount} lines -
- ${body} -
`; -} - -function taskFullBody(task, { expandedTerminals }) { - const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : ''; - return ` -
-
- ${escapeHtml(task.profileLabel)} -
${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}
-
-
- ${escapeHtml(statusLabel(task.status))} -
-
-
- ${formatDate(task.createdAt)} - ${task.finishedAt ? `完成 ${formatDate(task.finishedAt)}` : ''} - ${duration ? `耗时 ${escapeHtml(duration)}` : ''} - ${Number.isInteger(task.exitCode) ? `Exit ${task.exitCode}` : ''} - ${task.sessionId ? `Session ${escapeHtml(shortSession(task.sessionId))}` : ''} - ${task.parentTaskId ? `续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}` : ''} -
-
${escapeHtml(task.workspace)}
-
${escapeHtml(task.prompt)}
- ${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals, `${task.id}:stderr`)} - ${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)} - `; -} - -function renderActiveTaskCard(task) { - return `
- ${taskFullBody(task, { expandedTerminals: true })} -
`; -} - -function renderHistoryTaskCard(task) { - const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : ''; - const promptPreview = (task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 100); - const resumeButton = canResumeTask(task) - ? `` - : ''; - const openAttr = state.expandedTasks.has(task.id) ? ' open' : ''; - return `
- - ${escapeHtml(statusLabel(task.status))} - - ${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)} - ${promptPreview ? `${escapeHtml(promptPreview)}` : ''} - - ${resumeButton} - - ${formatDate(task.finishedAt || task.createdAt)} - ${duration ? escapeHtml(duration) : ''} - - - -
- ${taskFullBody(task, { expandedTerminals: false })} -
-
`; -} - -function renderTaskCard(task) { - return task.status === 'running' ? renderActiveTaskCard(task) : renderHistoryTaskCard(task); -} - -function renderTasks() { - const running = state.tasks.filter((task) => task.status === 'running').length; - const completed = state.tasks.filter((task) => task.status === 'completed').length; - const failed = state.tasks.filter((task) => task.status === 'failed').length; - $('taskSummary').textContent = `${running} 运行中 · ${completed} 已完成 · ${failed} 失败`; - - const activeTasks = state.tasks.filter((task) => task.status === 'running'); - const historyTasks = state.tasks.filter((task) => task.status !== 'running'); - const sections = []; - if (activeTasks.length) { - sections.push(`
-

运行中

${activeTasks.length} 个
-
${activeTasks.map(renderTaskCard).join('')}
-
`); - } - if (historyTasks.length) { - sections.push(`
-

历史 Session

${historyTasks.length} 个
-
${historyTasks.map(renderTaskCard).join('')}
-
`); - } - $('taskBoard').innerHTML = sections.join('') || '
暂无 Session
'; +
${rows}
+ `; + }).join(''); } function render() { - if (!state.selectedRepoId && state.repos[0]) state.selectedRepoId = state.repos[0].id; renderErrorBanner(); renderGpus(); - renderQuotas(); - renderRepos(); - renderBranches(); - renderProfiles(); - renderResumeTasks(); - renderTasks(); -} - -async function loadBranches() { - if (!state.selectedRepoId) { - state.branches = []; - state.selectedBranch = null; - renderBranches(); - return; - } - try { - state.branches = await request(`/api/branches?repo=${encodeURIComponent(state.selectedRepoId)}`); - } catch (error) { - state.branches = []; - toast(`读取 branches 失败: ${error.message}`, 'error'); - } - const repo = state.repos.find((item) => item.id === state.selectedRepoId); - if (!state.branches.some((branch) => branch.name === state.selectedBranch)) { - state.selectedBranch = state.branches.find((branch) => branch.name === repo?.defaultBranch)?.name || state.branches[0]?.name || null; - } - renderBranches(); - renderResumeTasks(); + renderLiveAgents(); } async function refresh() { @@ -455,10 +201,25 @@ async function refresh() { button.disabled = true; button.classList.add('loading'); try { - const data = await request('/api/dashboard'); - state = { ...state, ...data, errors: data.errors || {} }; + const [settings, gpus, liveAgents] = await Promise.allSettled([ + request('/api/settings'), + request('/api/gpus'), + request('/api/tmux/agents') + ]); + const errors = {}; + const value = (result, fallback, key) => { + if (result.status === 'fulfilled') return result.value; + errors[key] = String(result.reason?.message || result.reason || 'unknown error'); + return fallback; + }; + state = { + ...state, + settings: value(settings, state.settings, 'settings'), + gpus: value(gpus, [], 'gpus'), + liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'), + errors + }; render(); - await loadBranches(); } catch (error) { toast(`刷新失败: ${error.message}`, 'error'); } finally { @@ -551,105 +312,35 @@ $('gpuHostsChips').addEventListener('click', async (event) => { await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`); }); -$('repoBoard').addEventListener('click', (event) => { - const card = event.target.closest('.repo-card'); - if (!card) return; - state.selectedRepoId = card.dataset.id; - state.selectedBranch = null; - renderRepos(); - loadBranches(); -}); - -$('branchSelect').addEventListener('change', (event) => { - state.selectedBranch = event.target.value; - renderResumeTasks(); - renderAgentContext(); -}); - -async function selectResumeTask(taskId) { - const task = state.tasks.find((item) => item.id === taskId); - if (!task) return; - state.selectedRepoId = task.repoFullName; - state.selectedBranch = task.branch; - renderRepos(); - await loadBranches(); - $('resumeTaskSelect').value = task.id; - $('taskForm').scrollIntoView({ behavior: 'smooth', block: 'start' }); - $('promptInput').focus(); -} - -$('taskBoard').addEventListener('click', (event) => { - const button = event.target.closest('[data-resume-task]'); - if (!button) return; +$('liveAgentForm').addEventListener('submit', async (event) => { event.preventDefault(); - event.stopPropagation(); - selectResumeTask(button.dataset.resumeTask); -}); - -$('taskBoard').addEventListener('toggle', (event) => { - const target = event.target; - if (!(target instanceof HTMLDetailsElement)) return; - if (target.matches('details.history-task')) { - const id = target.dataset.taskId; - if (!id) return; - if (target.open) state.expandedTasks.add(id); - else state.expandedTasks.delete(id); - return; - } - if (target.matches('details.terminal')) { - const key = target.dataset.terminalKey; - if (!key) return; - if (target.open) state.expandedTerminals.add(key); - else state.expandedTerminals.delete(key); - } -}, true); - -async function submitTask(resume) { - const repo = state.repos.find((item) => item.id === state.selectedRepoId); - const prompt = $('promptInput').value.trim(); - if (!repo) { - toast('请先从左侧选择一个项目', 'warn'); - return; - } - if (!prompt) { - toast('请输入命令', 'warn'); - return; - } const payload = { - repo, - branch: $('branchSelect').value, - profileId: $('profileSelect').value, - prompt, - resumeTaskId: resume ? $('resumeTaskSelect').value : '' + engine: $('liveAgentEngine').value, + session: $('liveAgentSession').value.trim(), + cwd: $('liveAgentCwd').value.trim(), + windowName: $('liveAgentWindowName').value.trim() }; - if (resume && !payload.resumeTaskId) { - toast('没有可继续的 Session', 'warn'); + if (!payload.session || !payload.cwd) { + toast('请填写 session 和工作目录', 'warn'); return; } - const submitButtons = [$('startTaskButton'), $('resumeTaskButton')]; - submitButtons.forEach((btn) => { if (btn) btn.disabled = true; }); + const button = $('liveAgentLaunchButton'); + button.disabled = true; try { - await request('/api/tasks', { + const result = await request('/api/tmux/agents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); - $('promptInput').value = ''; - toast(resume ? 'Session 已继续' : '任务已启动', 'success'); + toast(`已在 ${result.session} 启动 ${result.engine}`, 'success'); + $('liveAgentWindowName').value = ''; await refresh(); } catch (error) { - toast(`启动任务失败: ${error.message}`, 'error'); + toast(`启动 agent 失败: ${error.message}`, 'error'); } finally { - submitButtons.forEach((btn) => { if (btn) btn.disabled = false; }); + button.disabled = false; } -} - -$('taskForm').addEventListener('submit', async (event) => { - event.preventDefault(); - await submitTask(false); }); -$('resumeTaskButton').addEventListener('click', () => submitTask(true)); - refresh(); setInterval(refresh, 60000); diff --git a/public/index.html b/public/index.html index 48c55fa..d36f012 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ Local Kanban - + @@ -14,7 +14,7 @@

Local Kanban

-

GPU · 额度 · 项目 · Agent

+

GPU · Live Agents

@@ -27,92 +27,61 @@ -
-
-
-
-

GPU

- -
+
+
+
+

GPU

+
-
-
- - -
-
-
- -
-
-
-

AI 额度

- -
-
-
-
+
+
+
+ + +
+
-
-
-
-
-

项目

- -
+
+
+
+

Live Agents

+
-
-
- -
-
-
-

Agent

- -
-
-
- 当前项目 - 未选择 - -
-
-
- - - -
- -
- - -
-
-
-
+
+
+ + + + + +
+
- + diff --git a/public/styles.css b/public/styles.css index 5341ef3..505eb74 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,61 +1,33 @@ :root { - color-scheme: light dark; - --bg: #f4f6fb; - --bg-elev: #ffffff; - --panel: #ffffff; - --panel-soft: #f8fafc; - --ink: #0f172a; - --ink-soft: #334155; - --muted: #64748b; - --line: #e2e8f0; - --line-strong: #cbd5e1; - --accent: #6366f1; - --accent-strong: #4f46e5; - --accent-soft: #eef2ff; - --accent-ink: #3730a3; - --good: #059669; - --good-soft: #d1fae5; - --warn: #d97706; - --warn-soft: #fef3c7; - --bad: #dc2626; - --bad-soft: #fee2e2; - --chip: #f1f5f9; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.04); - --shadow-md: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06); - --shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 16px 48px rgba(15, 23, 42, 0.06); + color-scheme: dark; + --bg: #0a0e1a; + --bg-elev: #111827; + --panel: #101626; + --panel-soft: #151d31; + --ink: #e5eaf3; + --ink-soft: #c3cbdc; + --muted: #8b96ad; + --line: #1f2940; + --line-strong: #2c3854; + --accent: #7c8cf8; + --accent-strong: #a0adfc; + --accent-soft: rgba(124, 140, 248, 0.13); + --accent-ink: #c7d2fe; + --good: #34d399; + --good-soft: rgba(52, 211, 153, 0.14); + --warn: #fbbf24; + --warn-soft: rgba(251, 191, 36, 0.14); + --bad: #f87171; + --bad-soft: rgba(248, 113, 113, 0.14); + --chip: #1c2540; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45); --radius: 14px; --radius-sm: 10px; --radius-pill: 999px; } -@media (prefers-color-scheme: dark) { - :root { - --bg: #0b1020; - --bg-elev: #131a2e; - --panel: #161e36; - --panel-soft: #1b2342; - --ink: #e2e8f0; - --ink-soft: #cbd5e1; - --muted: #94a3b8; - --line: #232c4a; - --line-strong: #2d3760; - --accent: #818cf8; - --accent-strong: #a5b4fc; - --accent-soft: rgba(129, 140, 248, 0.12); - --accent-ink: #c7d2fe; - --good: #34d399; - --good-soft: rgba(52, 211, 153, 0.14); - --warn: #fbbf24; - --warn-soft: rgba(251, 191, 36, 0.14); - --bad: #f87171; - --bad-soft: rgba(248, 113, 113, 0.14); - --chip: #1e2742; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45); - } -} - * { box-sizing: border-box; } [hidden] { display: none !important; } @@ -77,13 +49,13 @@ body { .bg-glow { position: fixed; inset: -10% -10% auto -10%; - height: 60vh; + height: 70vh; pointer-events: none; background: - radial-gradient(40% 60% at 15% 20%, rgba(99, 102, 241, 0.18), transparent 70%), - radial-gradient(35% 50% at 85% 10%, rgba(16, 185, 129, 0.14), transparent 70%), - radial-gradient(50% 60% at 50% 0%, rgba(56, 189, 248, 0.10), transparent 70%); - filter: blur(8px); + radial-gradient(45% 65% at 12% 15%, rgba(99, 102, 241, 0.14), transparent 70%), + radial-gradient(38% 55% at 88% 8%, rgba(16, 185, 129, 0.10), transparent 70%), + radial-gradient(55% 65% at 50% 0%, rgba(56, 189, 248, 0.07), transparent 70%); + filter: blur(10px); z-index: 0; } @@ -227,7 +199,7 @@ h3 { /* Panels */ .panel { - background: var(--panel); + background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, #2a3554), var(--panel) 120px); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 18px 20px; @@ -235,6 +207,8 @@ h3 { box-shadow: var(--shadow-md); } +.panel + .panel { margin-top: 18px; } + .panel-heading { display: flex; align-items: center; @@ -329,7 +303,7 @@ h3 { .gpu-thumbs { display: grid; - grid-template-columns: repeat(auto-fit, minmax(18px, 1fr)); + grid-template-columns: repeat(4, 1fr); gap: 4px; } @@ -721,7 +695,7 @@ select { -webkit-appearance: none; -moz-appearance: none; padding-right: 36px; - background-image: url("data:image/svg+xml;utf8,"); + background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; background-position: right 12px center; background-size: 14px; @@ -741,11 +715,6 @@ select option { color: var(--ink); } -@media (prefers-color-scheme: dark) { - select { - background-image: url("data:image/svg+xml;utf8,"); - } -} .select-field { position: relative; } @@ -1216,3 +1185,185 @@ details.task-card[open] > summary .task-summary-chevron { .inline-form button { justify-self: stretch; } .agent-context { flex-wrap: wrap; } } + +.live-agent-panel { margin-top: 18px; } + +/* status-dot glow for GPU thumbs */ +.gpu-thumb.idle { box-shadow: 0 0 6px color-mix(in srgb, var(--good) 30%, transparent); } +.gpu-thumb.busy { box-shadow: 0 0 6px color-mix(in srgb, var(--bad) 30%, transparent); } +.gpu-thumb.abnormal { box-shadow: 0 0 6px color-mix(in srgb, var(--warn) 30%, transparent); } + +/* Compact GPU board: hosts flow into columns, expanded host spans the row */ +.gpu-board { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 8px; +} + +.gpu-board .host-card { padding: 8px 12px; } + +.gpu-board .host-card.expanded { grid-column: 1 / -1; } + +.gpu-board .host-summary { + grid-template-columns: minmax(110px, auto) minmax(80px, 1fr) auto; + gap: 10px; +} + +.gpu-board .host-summary > span:first-child { + display: flex; + align-items: baseline; + gap: 8px; + white-space: nowrap; +} + +.gpu-board .gpu-thumb { height: 12px; border-radius: 4px; } + +.gpu-board .gpu-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 8px; + margin-top: 10px; +} + + +.live-agent-form { + display: grid; + grid-template-columns: minmax(110px, 0.6fr) minmax(150px, 0.9fr) minmax(240px, 1.8fr) minmax(150px, 1fr) auto; + gap: 12px; + align-items: end; + margin-bottom: 18px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.live-agent-form label { + display: grid; + gap: 6px; +} + +.live-agent-form .primary-button { white-space: nowrap; } + +.live-agent-board { + display: flex; + flex-direction: column; + gap: 10px; +} + +.agent-card { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel-soft); + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.agent-card:hover { + border-color: var(--line-strong); + box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06)); +} + +.agent-chip { + display: inline-flex; + align-items: center; + padding: 2px 10px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--chip); + color: var(--ink-soft); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; + flex-shrink: 0; +} + +.agent-chip.claude { color: #fbbf24; background: color-mix(in srgb, #f59e0b 14%, var(--chip)); } +.agent-chip.codex { color: #93c5fd; background: color-mix(in srgb, #3b82f6 14%, var(--chip)); } + +.agent-card-main { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + flex-shrink: 0; +} + +.agent-card-cwd { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.agent-card .secondary-button { + flex-shrink: 0; + border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); + color: var(--accent-ink); +} + +.agent-card .secondary-button:hover { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-strong); +} + +.agent-group { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel); + overflow: hidden; +} + +.agent-group-heading { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--panel-soft)), var(--panel-soft)); + border-bottom: 1px solid var(--line); +} + +.agent-group-heading::before { + content: '❯'; + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-weight: 700; + font-size: 12px; +} + +.agent-group-heading strong { + font-weight: 650; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + letter-spacing: 0.01em; +} + +.agent-group-heading .muted { font-size: 12px; } + +.agent-group-list { display: flex; flex-direction: column; } + +.agent-group-list .agent-card { + border: 0; + border-radius: 0; + background: transparent; +} + +.agent-group-list .agent-card + .agent-card { border-top: 1px solid var(--line); } + +.agent-group-list .agent-card:hover { + box-shadow: none; + background: var(--panel-soft); +} + +@media (max-width: 1024px) { + .live-agent-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 560px) { + .live-agent-form { grid-template-columns: 1fr; } + .agent-card { flex-wrap: wrap; } + .agent-card-cwd { flex-basis: 100%; } +}