diff --git a/portal/index.html b/portal/index.html
new file mode 100644
index 0000000..8e147c9
--- /dev/null
+++ b/portal/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ gahow-pc services
+
+
+
+
+
+
+
gahow-pc
+
Local services
+
+
+
+
+
+
+ 后端 API
+
+
+
+
+
diff --git a/portal/portal.css b/portal/portal.css
new file mode 100644
index 0000000..29f184a
--- /dev/null
+++ b/portal/portal.css
@@ -0,0 +1,166 @@
+:root {
+ color-scheme: light;
+ --bg: #f4f6f8;
+ --panel: #ffffff;
+ --ink: #17202c;
+ --muted: #697586;
+ --line: #d7dee8;
+ --accent: #2563eb;
+ --soft: #eef4ff;
+ --shadow: 0 16px 40px rgba(23, 32, 44, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background:
+ radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.12), transparent 28%),
+ radial-gradient(circle at 90% 18%, rgba(18, 128, 92, 0.11), transparent 26%),
+ var(--bg);
+ color: var(--ink);
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+.shell {
+ width: min(1180px, calc(100vw - 32px));
+ margin: 0 auto;
+ padding: 42px 0 56px;
+}
+
+.topbar {
+ margin-bottom: 30px;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 34px;
+ line-height: 1.08;
+ letter-spacing: 0;
+}
+
+h2 {
+ font-size: 14px;
+ margin-bottom: 0;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.topbar p,
+.service-desc {
+ color: var(--muted);
+}
+
+.topbar p {
+ margin-top: 6px;
+}
+
+.service-section {
+ margin-top: 18px;
+}
+
+.section-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+
+.section-heading span {
+ color: var(--muted);
+}
+
+.service-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.service-card,
+.api-section {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+}
+
+.service-card {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 8px 14px;
+ min-height: 168px;
+ padding: 18px;
+ color: inherit;
+ text-decoration: none;
+ overflow: hidden;
+}
+
+.service-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-1px);
+}
+
+.service-card.primary {
+ border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
+}
+
+.service-name {
+ grid-column: 1 / -1;
+ font-size: 22px;
+ font-weight: 700;
+}
+
+.service-icon {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ background: var(--soft);
+ color: var(--accent);
+ font-weight: 800;
+}
+
+.service-topline {
+ align-self: center;
+ color: var(--muted);
+ font-weight: 650;
+}
+
+.service-desc {
+ grid-column: 1 / -1;
+}
+
+.service-port {
+ grid-column: 1 / -1;
+ justify-self: start;
+ border-radius: 999px;
+ background: var(--soft);
+ color: #1849a9;
+ padding: 3px 9px;
+}
+
+.api-section {
+ margin-top: 18px;
+ padding: 14px 16px;
+ box-shadow: none;
+}
+
+.api-section summary {
+ cursor: pointer;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.api-section .service-grid {
+ margin-top: 14px;
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..7de4bf0
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,628 @@
+let state = {
+ gpus: [],
+ quotas: [],
+ repos: [],
+ branches: [],
+ agentProfiles: [],
+ tasks: [],
+ selectedRepoId: null,
+ selectedBranch: null,
+ expandedGpuHosts: new Set(),
+ settings: { gpuHosts: [] },
+ errors: {}
+};
+
+const $ = (id) => document.getElementById(id);
+
+function percent(value, total) {
+ if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
+ return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
+}
+
+function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ }[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;
+}
+
+function toast(message, tone = '') {
+ if (!message) return;
+ const host = $('toastHost');
+ if (!host) return;
+ const node = document.createElement('div');
+ node.className = `toast ${tone}`.trim();
+ node.textContent = message;
+ host.appendChild(node);
+ setTimeout(() => {
+ node.style.transition = 'opacity 200ms ease';
+ node.style.opacity = '0';
+ setTimeout(() => node.remove(), 220);
+ }, 4500);
+}
+
+async function request(path, options) {
+ const response = await fetch(path, options);
+ if (response.status === 401) {
+ window.location.href = '/login';
+ throw new Error('未登录');
+ }
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(text || `${response.status} ${response.statusText}`);
+ }
+ return response.json();
+}
+
+const ERROR_LABELS = {
+ settings: '设置',
+ gpus: 'GPU',
+ quotas: '额度',
+ repos: '项目列表',
+ agentProfiles: 'Agent 配置',
+ tasks: '任务历史'
+};
+
+function renderErrorBanner() {
+ const banner = $('errorBanner');
+ if (!banner) return;
+ const entries = Object.entries(state.errors || {});
+ if (!entries.length) {
+ banner.hidden = true;
+ banner.innerHTML = '';
+ return;
+ }
+ banner.hidden = false;
+ banner.innerHTML = `
+ 部分数据加载失败
+ ${entries.map(([key, message]) => (
+ `- ${escapeHtml(ERROR_LABELS[key] || key)} · ${escapeHtml(message)}
`
+ )).join('')}
+ `;
+}
+
+function renderGpuHostsChips() {
+ const chips = $('gpuHostsChips');
+ if (!chips) return;
+ const hosts = state.settings?.gpuHosts || [];
+ if (!hosts.length) {
+ chips.innerHTML = '未配置任何机器';
+ return;
+ }
+ const statusByHost = new Map(state.gpus.map((host) => [host.host, host.ok]));
+ chips.innerHTML = hosts.map((host) => {
+ const ok = statusByHost.get(host);
+ const cls = ok === true ? 'ok' : ok === false ? 'bad' : '';
+ const safe = escapeHtml(host);
+ return `
+
+ ${safe}
+
+ `;
+ }).join('');
+}
+
+function renderGpus() {
+ const okHosts = state.gpus.filter((host) => host.ok).length;
+ $('gpuSummary').textContent = state.gpus.length ? `${okHosts}/${state.gpus.length} 在线` : '尚未配置';
+ renderGpuHostsChips();
+ $('gpuBoard').innerHTML = state.gpus.length ? state.gpus.map((host) => {
+ if (!host.ok) {
+ return `
+ ${escapeHtml(host.host)}离线
+ ${escapeHtml(host.error || '无法获取 GPU 状态')}
+ `;
+ }
+ const idleCount = host.gpus.filter(isGpuIdle).length;
+ const totalCount = host.gpus.length;
+ const expanded = state.expandedGpuHosts.has(host.host);
+ const thumbRows = host.gpus.map((gpu) => {
+ const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
+ const busy = !isGpuIdle(gpu);
+ return ``;
+ }).join('');
+ const gpuRows = host.gpus.map((gpu) => {
+ const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
+ return `
+
+ #${gpu.index} ${escapeHtml(gpu.name)}
+ ${gpu.gpuUtilizationPercent}% GPU
+
+
+
+ ${gpu.memoryUsedMiB} / ${gpu.memoryTotalMiB} MiB
+ ${memoryPercent}% 显存
+
+
+ `;
+ }).join('');
+ return `
+
+ ${expanded ? `${gpuRows || '未发现 GPU'}
` : ''}
+ `;
+ }).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 ? '已更新' : '未配置'}
+
+ ${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) {
+ if (!content) return '';
+ const lineCount = content.split('\n').length;
+ const body = `${escapeHtml(content)}`;
+ if (!expanded) {
+ 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)}
+ ${renderTerminal('stdout', task.stdout, '', expandedTerminals)}
+ `;
+}
+
+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)
+ ? ``
+ : '';
+ 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
';
+}
+
+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();
+}
+
+async function refresh() {
+ const button = $('refreshButton');
+ button.disabled = true;
+ button.classList.add('loading');
+ try {
+ const data = await request('/api/dashboard');
+ state = { ...state, ...data, errors: data.errors || {} };
+ render();
+ await loadBranches();
+ } catch (error) {
+ toast(`刷新失败: ${error.message}`, 'error');
+ } finally {
+ button.disabled = false;
+ button.classList.remove('loading');
+ }
+}
+
+$('refreshButton').addEventListener('click', refresh);
+
+$('logoutButton').addEventListener('click', async () => {
+ try {
+ await fetch('/api/auth/logout', { method: 'POST' });
+ } finally {
+ window.location.href = '/login';
+ }
+});
+
+$('gpuBoard').addEventListener('click', (event) => {
+ const button = event.target.closest('.host-summary');
+ if (!button) return;
+ const host = button.dataset.host;
+ if (state.expandedGpuHosts.has(host)) state.expandedGpuHosts.delete(host);
+ else state.expandedGpuHosts.add(host);
+ renderGpus();
+});
+
+async function saveGpuHosts(gpuHosts, successMessage) {
+ try {
+ const settings = await request('/api/settings/gpu-hosts', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ gpuHosts })
+ });
+ state.settings = settings;
+ renderGpuHostsChips();
+ if (successMessage) toast(successMessage, 'success');
+ await refresh();
+ return true;
+ } catch (error) {
+ toast(`保存失败: ${error.message}`, 'error');
+ return false;
+ }
+}
+
+function setHostAdding(adding) {
+ const trigger = $('hostAddTrigger');
+ const form = $('hostAddForm');
+ const input = $('hostAddInput');
+ if (!trigger || !form) return;
+ form.hidden = !adding;
+ trigger.hidden = adding;
+ if (adding && input) {
+ input.value = '';
+ input.focus();
+ }
+}
+
+$('hostAddTrigger').addEventListener('click', () => setHostAdding(true));
+$('hostAddCancel').addEventListener('click', () => setHostAdding(false));
+$('hostAddInput').addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setHostAdding(false);
+ }
+});
+
+$('hostAddForm').addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const name = $('hostAddInput').value.trim();
+ if (!name) {
+ toast('请输入主机名', 'warn');
+ return;
+ }
+ const current = state.settings?.gpuHosts || [];
+ if (current.includes(name)) {
+ toast(`${name} 已存在`, 'warn');
+ return;
+ }
+ const ok = await saveGpuHosts([...current, name], `已添加 ${name}`);
+ if (ok) setHostAdding(false);
+});
+
+$('gpuHostsChips').addEventListener('click', async (event) => {
+ const button = event.target.closest('[data-remove-host]');
+ if (!button) return;
+ const host = button.dataset.removeHost;
+ const current = state.settings?.gpuHosts || [];
+ if (!current.includes(host)) return;
+ 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;
+ event.preventDefault();
+ event.stopPropagation();
+ selectResumeTask(button.dataset.resumeTask);
+});
+
+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 : ''
+ };
+ if (resume && !payload.resumeTaskId) {
+ toast('没有可继续的 Session', 'warn');
+ return;
+ }
+ const submitButtons = [$('startTaskButton'), $('resumeTaskButton')];
+ submitButtons.forEach((btn) => { if (btn) btn.disabled = true; });
+ try {
+ await request('/api/tasks', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ $('promptInput').value = '';
+ toast(resume ? 'Session 已继续' : '任务已启动', 'success');
+ await refresh();
+ } catch (error) {
+ toast(`启动任务失败: ${error.message}`, 'error');
+ } finally {
+ submitButtons.forEach((btn) => { if (btn) btn.disabled = false; });
+ }
+}
+
+$('taskForm').addEventListener('submit', async (event) => {
+ event.preventDefault();
+ await submitTask(false);
+});
+
+$('resumeTaskButton').addEventListener('click', () => submitTask(true));
+
+refresh();
+setInterval(refresh, 30000);
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..48c55fa
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+ Local Kanban
+
+
+
+
+
+
+
+
LK
+
+
Local Kanban
+
GPU · 额度 · 项目 · Agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前项目
+ 未选择
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 0000000..77602f8
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Login · Local Kanban
+
+
+
+
+
+
+
+
+
diff --git a/public/login.js b/public/login.js
new file mode 100644
index 0000000..5bb44e3
--- /dev/null
+++ b/public/login.js
@@ -0,0 +1,18 @@
+const form = document.getElementById('loginForm');
+const tokenInput = document.getElementById('tokenInput');
+const loginError = document.getElementById('loginError');
+
+form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ loginError.textContent = '';
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ token: tokenInput.value })
+ });
+ if (response.ok) {
+ window.location.href = '/';
+ return;
+ }
+ loginError.textContent = response.status === 429 ? '尝试次数过多' : 'Token 无效';
+});
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..1b96f5f
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,1213 @@
+: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);
+ --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; }
+
+html, body { height: 100%; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ min-height: 100vh;
+ position: relative;
+ overflow-x: hidden;
+}
+
+.bg-glow {
+ position: fixed;
+ inset: -10% -10% auto -10%;
+ height: 60vh;
+ 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);
+ z-index: 0;
+}
+
+button, input, select, textarea {
+ font: inherit;
+ color: inherit;
+}
+
+button { cursor: pointer; }
+
+.shell {
+ position: relative;
+ z-index: 1;
+ width: min(1440px, calc(100vw - 32px));
+ margin: 0 auto;
+ padding: 24px 0 24px;
+}
+
+h1, h2, h3, p { margin: 0; }
+
+h1 {
+ font-size: 24px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.15;
+}
+
+h2 {
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink-soft);
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.muted { color: var(--muted); }
+
+/* Topbar */
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.brand-mark {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 14px;
+ letter-spacing: 0.06em;
+ color: #fff;
+ background: linear-gradient(135deg, #6366f1, #8b5cf6 55%, #06b6d4);
+ box-shadow: 0 6px 18px rgba(99, 102, 241, 0.35);
+}
+
+.top-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.icon-button {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ display: grid;
+ place-items: center;
+ transition: transform 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
+ box-shadow: var(--shadow-sm);
+}
+
+.icon-button svg { width: 18px; height: 18px; }
+
+.icon-button:hover {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
+ box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 22%, transparent);
+}
+
+.icon-button:active { transform: translateY(1px); }
+
+.icon-button:disabled { opacity: 0.6; cursor: progress; }
+
+.icon-button.loading svg { animation: spin 700ms linear infinite; }
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.ghost-button {
+ height: 40px;
+ padding: 0 14px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
+ box-shadow: var(--shadow-sm);
+}
+
+.ghost-button:hover {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
+}
+
+/* Grid layout */
+.grid {
+ display: grid;
+ gap: 18px;
+}
+
+.dashboard-grid {
+ grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
+ margin-bottom: 18px;
+}
+
+.work-grid {
+ grid-template-columns: minmax(260px, 1fr) minmax(0, 2.4fr);
+ align-items: start;
+}
+
+/* Panels */
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 18px 18px 20px;
+ min-width: 0;
+ box-shadow: var(--shadow-md);
+}
+
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.panel-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: var(--radius-pill);
+ background: var(--accent-soft);
+ color: var(--accent-ink);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+/* Error banner */
+.error-banner {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border-radius: var(--radius-sm);
+ border: 1px solid color-mix(in srgb, var(--bad) 35%, transparent);
+ background: var(--bad-soft);
+ color: var(--bad);
+ font-size: 13px;
+}
+
+.error-banner ul { margin: 4px 0 0; padding-left: 20px; }
+.error-banner li { line-height: 1.5; }
+
+/* GPU board */
+.gpu-board,
+.quota-board,
+.repo-board,
+.task-board { display: grid; gap: 12px; }
+
+.host-card,
+.quota-card,
+.repo-card,
+.task-card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 14px;
+ background: var(--panel);
+ min-width: 0;
+ transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
+}
+
+.host-card:hover,
+.quota-card:hover { border-color: var(--line-strong); }
+
+.host-title,
+.repo-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+.host-summary {
+ width: 100%;
+ display: grid;
+ grid-template-columns: minmax(160px, 1fr) minmax(120px, 2fr) auto;
+ align-items: center;
+ gap: 14px;
+ border: 0;
+ background: transparent;
+ color: var(--ink);
+ padding: 0;
+ text-align: left;
+}
+
+.host-summary > span:first-child {
+ display: grid;
+ gap: 3px;
+}
+
+.host-summary strong { font-weight: 650; }
+
+.gpu-thumbs {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18px, 1fr));
+ gap: 4px;
+}
+
+.gpu-thumb {
+ height: 16px;
+ border-radius: 5px;
+ background: var(--chip);
+ border: 1px solid var(--line);
+}
+
+.gpu-thumb.idle {
+ background: color-mix(in srgb, var(--good) 22%, transparent);
+ border-color: color-mix(in srgb, var(--good) 45%, transparent);
+}
+
+.gpu-thumb.busy {
+ background: color-mix(in srgb, var(--bad) 22%, transparent);
+ border-color: color-mix(in srgb, var(--bad) 50%, transparent);
+}
+
+.gpu-list { display: grid; gap: 10px; margin-top: 14px; }
+
+.gpu-detail-card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 12px;
+ background: var(--panel-soft);
+}
+
+.metric-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 6px;
+ font-size: 13px;
+}
+
+.metric-row strong { font-weight: 650; color: var(--ink); }
+
+/* Meters */
+.meter {
+ display: block;
+ width: 100%;
+ height: 8px;
+ appearance: none;
+ border: 0;
+ background: transparent;
+ margin-top: 4px;
+}
+
+.meter::-webkit-meter-bar {
+ height: 8px;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: var(--chip);
+}
+
+.meter::-webkit-meter-optimum-value {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 65%, var(--accent)));
+}
+
+.meter::-webkit-meter-suboptimum-value {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--warn), #f59e0b);
+}
+
+.meter::-webkit-meter-even-less-good-value {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--bad), #f87171);
+}
+
+.meter::-moz-meter-bar {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 65%, var(--accent)));
+}
+
+/* Status text */
+.status-ok { color: var(--good); font-weight: 600; }
+.status-warn { color: var(--warn); font-weight: 600; }
+.status-bad { color: var(--bad); font-weight: 600; }
+
+/* Chip */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 22px;
+ padding: 2px 9px;
+ border-radius: var(--radius-pill);
+ background: var(--chip);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+/* Repos */
+.project-panel {
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 32px);
+ display: flex;
+ flex-direction: column;
+}
+
+.project-panel > .panel-heading { flex: 0 0 auto; }
+
+.project-panel .repo-board {
+ grid-template-columns: 1fr;
+ flex: 0 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 2px 6px 2px 2px;
+ scroll-padding-block: 4px;
+}
+
+.project-panel .repo-card {
+ display: grid;
+ gap: 6px;
+ padding: 12px 14px;
+ cursor: pointer;
+}
+
+.project-panel .repo-title { margin-bottom: 0; }
+
+.repo-card.selected {
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
+}
+
+.repo-card:hover { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
+
+.repo-meta,
+.task-meta {
+ color: var(--muted);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+/* Task form */
+.task-form {
+ display: grid;
+ gap: 14px;
+ margin-bottom: 18px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.task-form label,
+.inline-form label {
+ display: grid;
+ gap: 6px;
+}
+
+.field-label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--muted);
+}
+
+.task-controls {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(160px, 1fr));
+ gap: 12px;
+ align-items: end;
+}
+
+.agent-context {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ margin-bottom: 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: linear-gradient(180deg, var(--accent-soft), transparent);
+}
+
+.agent-context-label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.agent-context-repo {
+ font-weight: 650;
+ color: var(--ink);
+ overflow-wrap: anywhere;
+}
+
+.agent-context-repo.empty { color: var(--muted); font-weight: 500; }
+
+.prompt-field { max-width: 980px; }
+
+.inline-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.gpu-hosts-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+
+.gpu-hosts-chips {
+ display: contents;
+}
+
+.host-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 4px 0 10px;
+ height: 26px;
+ border-radius: var(--radius-pill);
+ background: var(--chip);
+ color: var(--ink-soft);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ transition: background 120ms ease;
+}
+
+.host-chip:hover {
+ background: color-mix(in srgb, var(--ink) 6%, var(--chip));
+}
+
+.host-chip-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 999px;
+ background: var(--muted);
+ flex: 0 0 auto;
+}
+
+.host-chip.ok .host-chip-dot {
+ background: var(--good);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--good) 22%, transparent);
+}
+
+.host-chip.bad .host-chip-dot {
+ background: var(--bad);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--bad) 22%, transparent);
+}
+
+.host-chip-label {
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ height: 100%;
+}
+
+.host-chip-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ padding: 0;
+ flex: 0 0 auto;
+ transition: color 120ms ease, background 120ms ease;
+}
+
+.host-chip-remove svg {
+ width: 10px;
+ height: 10px;
+ display: block;
+}
+
+.host-chip-remove:hover {
+ color: var(--bad);
+ background: color-mix(in srgb, var(--bad) 18%, transparent);
+}
+
+.host-chip-empty {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.host-add-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 0 12px;
+ height: 26px;
+ border-radius: var(--radius-pill);
+ border: 1px dashed var(--line-strong);
+ background: transparent;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
+}
+
+.host-add-button > span:first-child {
+ font-size: 14px;
+ line-height: 1;
+ margin-top: -1px;
+}
+
+.host-add-button:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.host-add-form {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.host-add-form input {
+ width: 160px;
+ height: 26px;
+ padding: 0 10px;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.host-add-form .primary-button.small,
+.host-add-form .secondary-button.small {
+ height: 26px;
+ padding: 0 12px;
+ line-height: 1;
+}
+
+.link-button {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 12px;
+ padding: 4px 8px;
+ border-radius: 6px;
+ transition: color 120ms ease, background 120ms ease;
+}
+
+.link-button:hover {
+ color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.primary-button.small,
+.secondary-button.small {
+ padding: 6px 10px;
+ font-size: 12px;
+}
+
+select, input, textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elev);
+ color: var(--ink);
+ padding: 10px 12px;
+ transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
+}
+
+select:hover, input:hover, textarea:hover { border-color: var(--line-strong); }
+
+select:focus, input:focus, textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
+}
+
+select {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ padding-right: 36px;
+ background-image: url("data:image/svg+xml;utf8,");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ background-size: 14px;
+ cursor: pointer;
+ text-overflow: ellipsis;
+}
+
+select::-ms-expand { display: none; }
+
+select:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+select option {
+ background: var(--bg-elev);
+ color: var(--ink);
+}
+
+@media (prefers-color-scheme: dark) {
+ select {
+ background-image: url("data:image/svg+xml;utf8,");
+ }
+}
+
+.select-field { position: relative; }
+
+textarea {
+ resize: vertical;
+ min-height: 120px;
+ line-height: 1.55;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ font-size: 13px;
+}
+
+/* Buttons */
+.primary-button,
+.secondary-button {
+ border-radius: var(--radius-sm);
+ padding: 10px 16px;
+ font-weight: 600;
+ border: 1px solid transparent;
+ transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
+}
+
+.primary-button {
+ background: linear-gradient(180deg, var(--accent), var(--accent-strong));
+ color: #fff;
+ box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 35%, transparent);
+}
+
+.primary-button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 40%, transparent);
+}
+
+.primary-button:active { transform: translateY(0); }
+
+.primary-button:disabled,
+.secondary-button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.secondary-button {
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ border-color: var(--line);
+}
+
+.secondary-button:hover {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
+}
+
+.secondary-button.small { padding: 6px 10px; font-size: 12px; }
+
+.inline-form button { justify-self: start; }
+
+.form-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+/* Task cards */
+.task-board, .task-section, .task-list { display: grid; gap: 12px; }
+
+.task-section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.task-card { padding: 14px 16px; }
+
+article.task-card { display: grid; gap: 10px; }
+
+details.task-card {
+ display: block;
+ padding: 0;
+ overflow: hidden;
+}
+
+details.task-card > summary {
+ list-style: none;
+ cursor: pointer;
+ padding: 12px 14px;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto auto;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+details.task-card > summary::-webkit-details-marker { display: none; }
+details.task-card > summary::marker { display: none; }
+
+details.task-card[open] > summary {
+ border-bottom: 1px solid var(--line);
+}
+
+details.task-card > .task-body {
+ display: grid;
+ gap: 10px;
+ padding: 14px 16px;
+}
+
+.task-summary-main {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.task-summary-title {
+ font-weight: 600;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.task-summary-prompt {
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.task-summary-resume {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ min-width: 88px;
+}
+
+.task-summary-meta {
+ display: grid;
+ grid-template-columns: 92px 60px;
+ align-items: center;
+ column-gap: 10px;
+ font-size: 12px;
+ white-space: nowrap;
+ text-align: right;
+}
+
+.task-summary-duration { text-align: right; }
+
+.task-summary-chevron {
+ width: 14px;
+ color: var(--muted);
+ font-size: 18px;
+ line-height: 1;
+ text-align: center;
+ transition: transform 120ms ease;
+}
+
+details.task-card[open] > summary .task-summary-chevron {
+ transform: rotate(90deg);
+}
+
+.active-task {
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent), var(--shadow-sm);
+}
+
+.history-task { background: var(--panel-soft); }
+
+.history-task:hover { border-color: var(--line-strong); }
+
+@media (max-width: 720px) {
+ details.task-card > summary {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ }
+ .task-summary-meta { display: none; }
+ .task-summary-resume { display: none; }
+}
+
+.task-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.task-head strong { font-weight: 650; font-size: 14px; }
+
+.task-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.task-facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.task-facts span {
+ min-height: 22px;
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 9px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--bg-elev);
+}
+
+.session-id { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
+
+.workspace-path {
+ font-size: 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ color: var(--muted);
+}
+
+.task-prompt {
+ color: var(--ink);
+ overflow-wrap: anywhere;
+ font-size: 13px;
+ line-height: 1.55;
+ padding: 10px 12px;
+ background: var(--panel-soft);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+
+
+/* Status pill colors */
+.status-chip {
+ padding: 3px 10px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.status-running .status-chip {
+ background: var(--accent-soft);
+ color: var(--accent-ink);
+}
+
+.status-completed .status-chip {
+ background: var(--good-soft);
+ color: var(--good);
+}
+
+.status-failed .status-chip {
+ background: var(--bad-soft);
+ color: var(--bad);
+}
+
+.status-running .status-chip::before,
+.status-completed .status-chip::before,
+.status-failed .status-chip::before {
+ content: "";
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ margin-right: 6px;
+ background: currentColor;
+}
+
+.status-running .status-chip::before {
+ animation: pulse 1.6s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.6); opacity: 0.4; }
+}
+
+/* Empty state */
+.empty-state {
+ border: 1px dashed var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 28px 20px;
+ color: var(--muted);
+ text-align: center;
+ background: var(--panel-soft);
+}
+
+/* Terminal */
+.terminal {
+ margin: 10px 0 0;
+ border: 1px solid #1f2940;
+ border-radius: var(--radius-sm);
+ background: #0b1020;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.terminal.stderr { border-color: #5b2b2b; }
+
+.terminal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #1f2940;
+ background: linear-gradient(180deg, #131a2e, #0f1426);
+ color: #9ca3af;
+ font: 11px/1.3 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.terminal > summary.terminal-head { cursor: pointer; list-style: none; }
+.terminal > summary.terminal-head::-webkit-details-marker { display: none; }
+.terminal > summary.terminal-head::after {
+ content: "▸";
+ margin-left: 8px;
+ transition: transform 120ms ease;
+}
+.terminal[open] > summary.terminal-head::after { transform: rotate(90deg); }
+
+.terminal.stderr .terminal-head {
+ border-bottom-color: #5b2b2b;
+ background: linear-gradient(180deg, #25131b, #1a0e14);
+ color: #fca5a5;
+}
+
+.terminal-body {
+ display: block;
+ width: 100%;
+ max-height: min(52vh, 520px);
+ margin: 0;
+ overflow: auto;
+ padding: 12px 14px;
+ background: #0b1020;
+ color: #e5e7eb;
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ tab-size: 2;
+}
+
+.terminal-body:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* Toasts */
+.toast-host {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ display: grid;
+ gap: 10px;
+ z-index: 50;
+ max-width: min(360px, calc(100vw - 32px));
+}
+
+.toast {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-left: 4px solid var(--accent);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px;
+ box-shadow: var(--shadow-lg);
+ font-size: 13px;
+ color: var(--ink-soft);
+ animation: slideIn 200ms ease;
+}
+
+.toast.error { border-left-color: var(--bad); color: var(--bad); }
+.toast.success { border-left-color: var(--good); color: var(--good); }
+.toast.warn { border-left-color: var(--warn); color: var(--warn); }
+
+@keyframes slideIn {
+ from { transform: translateY(10px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* Login */
+.login-shell {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ position: relative;
+ z-index: 1;
+}
+
+.login-panel {
+ width: min(420px, 100%);
+ display: grid;
+ gap: 18px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 28px;
+ box-shadow: var(--shadow-lg);
+}
+
+.login-panel h1 {
+ font-size: 26px;
+ background: linear-gradient(135deg, var(--accent), #06b6d4);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.login-panel label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.login-panel button {
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: linear-gradient(180deg, var(--accent), var(--accent-strong));
+ color: #fff;
+ padding: 12px 14px;
+ font-weight: 600;
+ box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 35%, transparent);
+ transition: transform 120ms ease, box-shadow 120ms ease;
+}
+
+.login-panel button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 40%, transparent);
+}
+
+.login-panel p { min-height: 18px; font-size: 13px; }
+
+/* Responsive */
+@media (max-width: 1180px) {
+ .work-grid { grid-template-columns: 1fr; }
+ .project-panel {
+ position: static;
+ max-height: none;
+ display: block;
+ }
+ .project-panel .repo-board {
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ max-height: 320px;
+ min-height: 0;
+ flex: initial;
+ }
+}
+
+@media (max-width: 1024px) {
+ .task-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 920px) {
+ .dashboard-grid { grid-template-columns: 1fr; }
+ .shell { width: min(100vw - 24px, 760px); padding-top: 18px; }
+}
+
+@media (max-width: 560px) {
+ .task-controls { grid-template-columns: 1fr; }
+ .topbar, .panel-heading, .task-head {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .task-actions { justify-content: flex-start; }
+ .inline-form { grid-template-columns: 1fr; }
+ .inline-form button { justify-self: stretch; }
+ .agent-context { flex-wrap: wrap; }
+}