console: 一页式控制台排版 + 数据卡片

保持 Muse 风格(白底、细线、灰/浅蓝气泡),不再四栏切视图:
顶栏(连接/健康/配额/刷新 + 锚点导航)+ 6 张数据卡片
(健康、配额、任务、Workers、项目、项目数据,均取自已有
store 数据,未加载不以 0 充数)+ 所有模块按顺序铺在一页
(派发、任务列表、对话+详情、项目、环境、Workers、技能、
自动化、分诊、管理区、API Key)。图标栏与右侧栏删除,
内容分别移到顶栏与各自模块下;今天列表与任务列表重复,
不再单独保留;对话为空时不再重复渲染派发表单。
含自动选第一个项目修复(主要聊天不再空)与继续消息
历史,console 标记单测本地 53 项通过。
This commit is contained in:
host committed 2026-10-29 09:57:26 +08:00
1 parent a13762cfbe
commit 8b455c6554
9 files changed
+263 -272

No files matched your search

+108 -50
View File
@@ -1,72 +1,130 @@
import { useState } from "react";
import { ConsoleContext } from "./context";
import { ConsoleContext, useConsole } from "./context";
import { useConsoleStore } from "./hooks/useConsoleStore";
import { useAutoRefresh, useIntervalMs } from "./hooks/useAutoRefresh";
import IconRail, { type View } from "./components/IconRail";
import DataCards from "./sections/DataCards";
import DispatchSection from "./sections/DispatchSection";
import TaskListSection from "./sections/TaskListSection";
import ConversationSection from "./sections/ConversationSection";
import TaskDetailPanel from "./sections/TaskDetailPanel";
import ApiKeySection from "./sections/ApiKeySection";
import AdminSection from "./sections/AdminSection";
import ProjectsSection from "./sections/ProjectsSection";
import MembersPanel from "./sections/MembersPanel";
import EnvSection from "./sections/EnvSection";
import EnvContextPanel from "./sections/EnvContextPanel";
import WorkersSection from "./sections/WorkersSection";
import HealthPanel from "./sections/HealthPanel";
import SkillsSection from "./sections/SkillsSection";
import SkillsToolsPanel from "./sections/SkillsToolsPanel";
import AutomationsSection from "./sections/AutomationsSection";
import TriageSection from "./sections/TriageSection";
import TaskListSection from "./sections/TaskListSection";
import ConversationSection from "./sections/ConversationSection";
import RightPanel from "./sections/RightPanel";
// Layout only, Muse web/desktop style: no big header bar.
// Icon rail (56px) + left list (260px) + centre conversation (elastic,
// content centred) + right panel (300px, profile/today/detail). The
// centre top bar is minimal: a back arrow (back to the task view) on
// the left, and, only when the right panel is closed, a button to
// reopen it — the right panel's own X closes it. Selecting a task in
// the left list switches the centre conversation and the right detail
// to that task together; selecting a module switches the centre to
// that module and the right detail to its context.
// Layout only: ONE scrollable console page, Muse web/desktop tokens
// (white, hairlines, grey/blue bubbles) but console ordering — no
// icon-rail column, no left list column, no right panel column, no
// view switching. Everything is on the page: a slim sticky top bar
// (name + real connection status + health + quota + refresh stamp +
// anchor nav), a data-cards row, then every module as a section in
// order. The old right panel's content moved here: its profile/status
// line is the top bar, its 今天 list is redundant with the full task
// list section, and its detail/context panels sit under their own
// sections below (detail under 对话, members under 项目, and so on).
// The old icon rail's module switching is the anchor nav.
const NAV: Array<[string, string]> = [
["#sec-dispatch", "派发"], ["#sec-tasks", "任务"], ["#sec-conv", "对话"],
["#sec-projects", "项目"], ["#sec-env", "环境"], ["#sec-workers", "Workers"],
["#sec-skills", "技能"], ["#sec-automations", "自动化"], ["#sec-triage", "分诊"],
["#sec-admin", "管理区"], ["#sec-key", "API Key"],
];
function TopBar() {
return (
<header className="dash-topbar">
<div className="dash-topbar-row">
<span className="dash-brand">Codex 命令台</span>
<TopStatus />
<span className="muted dash-hide-sm"><TopHealth /></span>
<span className="muted dash-hide-sm"><TopQuota /></span>
<span className="muted dash-stamp"><TopStamp /></span>
</div>
<nav className="dash-nav" aria-label="页面导航">
{NAV.map(([href, label]) => <a key={href} href={href}>{label}</a>)}
</nav>
</header>
);
}
// Tiny readers so the top bar re-renders from the store.
function TopStatus() {
const ctx = useConsole();
return <span className={ctx.keySaved ? "dash-status ok" : "dash-status off"}>{ctx.keySaved ? "● 已连接" : "○ 未连接"}</span>;
}
function TopHealth() {
return <>{useConsole().healthText}</>;
}
function TopQuota() {
return <>{useConsole().quotaText}</>;
}
function TopStamp() {
return <>自动刷新 · 每 10 秒 · 已刷新 {useConsole().refreshStamp}</>;
}
export default function App() {
const store = useConsoleStore();
const [view, setView] = useState<View>("task");
const [rightOpen, setRightOpen] = useState(true);
useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000);
useIntervalMs(() => store.refreshSlow(), 60000);
const centre = (() => {
switch (view) {
case "projects": return <ProjectsSection />;
case "env": return <EnvSection />;
case "workers": return <WorkersSection />;
case "skills": return <SkillsSection />;
case "automations": return <AutomationsSection />;
case "triage": return <TriageSection />;
case "admin": return <AdminSection />;
case "key": return <ApiKeySection />;
default: return <ConversationSection />;
}
})();
const scrollToDispatch = () => {
document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" });
};
return (
<ConsoleContext.Provider value={store}>
<div className={"muse-app" + (rightOpen ? "" : " no-right")}>
<IconRail view={view} onSelect={setView} />
<aside className="list-col">
<TaskListSection onNew={() => setView("task")} />
</aside>
<main className="center-col">
<div className="center-topbar">
<button type="button" className="icon-btn" aria-label="返回任务" title="返回任务" onClick={() => setView("task")}>←</button>
{rightOpen ? <span className="muted">Codex 命令台</span> : (
<button type="button" onClick={() => setRightOpen(true)}>显示右侧栏</button>
)}
<TopBar />
<main className="dash-page">
<DataCards />
<section id="sec-dispatch" className="dash-section">
<DispatchSection />
</section>
<section id="sec-tasks" className="dash-section card">
<h2>任务列表</h2>
<TaskListSection onNew={scrollToDispatch} />
</section>
<section id="sec-conv" className="dash-section">
<div className="card">
<h2>对话</h2>
<ConversationSection />
</div>
<div className="center-inner">{centre}</div>
</main>
{rightOpen ? (
<aside className="right-col">
<RightPanel view={view} onSelect={setView} onClose={() => setRightOpen(false)} />
</aside>
) : null}
</div>
<TaskDetailPanel />
</section>
<section id="sec-projects" className="dash-section">
<ProjectsSection />
<MembersPanel />
</section>
<section id="sec-env" className="dash-section">
<EnvSection />
<EnvContextPanel />
</section>
<section id="sec-workers" className="dash-section">
<WorkersSection />
<HealthPanel />
</section>
<section id="sec-skills" className="dash-section">
<SkillsSection />
<SkillsToolsPanel />
</section>
<section id="sec-automations" className="dash-section">
<AutomationsSection />
</section>
<section id="sec-triage" className="dash-section">
<TriageSection />
</section>
<section id="sec-admin" className="dash-section">
<AdminSection />
</section>
<section id="sec-key" className="dash-section">
<ApiKeySection />
</section>
</main>
</ConsoleContext.Provider>
);
}
@@ -1,47 +0,0 @@
import type { ReactNode } from "react";
// Far-left icon rail from the Muse screenshot: one simple inline-SVG
// icon per module, no emoji, no external image. This replaces LeftNav
// as the module switcher; the left list panel below it is the task list.
export type View = "task" | "projects" | "env" | "workers" | "skills" | "automations" | "triage" | "admin" | "key";
function Svg({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{children}
</svg>
);
}
export function ModuleIcon({ view }: { view: View }) {
switch (view) {
case "task": return <Svg><path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 21l2-5.6A8.5 8.5 0 1 1 21 11.5Z" /></Svg>;
case "projects": return <Svg><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" /><path d="M14 3v5h5" /><path d="M9 13h6M9 17h6" /></Svg>;
case "env": return <Svg><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></Svg>;
case "workers": return <Svg><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></Svg>;
case "skills": return <Svg><path d="M9 18h6M10 21h4" /><path d="M12 3a6 6 0 0 0-3.5 10.8c.8.6 1.5 1.4 1.5 2.2h4c0-.8.7-1.6 1.5-2.2A6 6 0 0 0 12 3Z" /></Svg>;
case "automations": return <Svg><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></Svg>;
case "triage": return <Svg><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></Svg>;
case "admin": return <Svg><path d="M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z" /><path d="M9 12l2 2 4-4" /></Svg>;
case "key": return <Svg><circle cx="8" cy="15" r="4" /><path d="M11.5 11.5L20 3" /><path d="M17 6l2 2M20 3l-1 3" /></Svg>;
default: return <Svg><circle cx="12" cy="12" r="9" /></Svg>;
}
}
const ITEMS: Array<[View, string]> = [
["task", "任务"], ["projects", "项目"], ["env", "环境"], ["workers", "Workers"],
["skills", "技能与 MCP"], ["automations", "自动化"], ["triage", "分诊"],
["admin", "管理区"], ["key", "API Key"],
];
export default function IconRail({ view, onSelect }: { view: View; onSelect: (v: View) => void }) {
return (
<nav className="icon-rail" aria-label="模块快捷方式">
{ITEMS.map(([v, label]) => (
<button key={v} type="button" title={label} aria-label={label} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>
<ModuleIcon view={v} />
</button>
))}
</nav>
);
}
@@ -35,6 +35,18 @@ export function useConsoleStore(): ConsoleCtx {
void tools.refreshTriage(id);
}, [catalog, taskList, tools]);
// Fix: the old single page's project <select> defaulted to its first
// option once loaded, so tasks loaded without a click. The React
// version is a controlled select with projectId "" on every reload,
// so without this the task list stayed on "先选项目" forever and
// auto-refresh never fixed it. Auto-select the first project only
// while nothing is selected; an explicit user choice is never
// overridden.
const firstProjectId = catalog.projects[0]?.id;
useEffect(() => {
if (!catalog.projectId && firstProjectId) selectProject(firstProjectId);
}, [catalog.projectId, firstProjectId, selectProject]);
const saveKeyInput = useCallback((v: string) => {
catalog.saveKeyInput(v);
if (!v.trim()) return;
@@ -1,18 +1,16 @@
import { useConsole } from "../context";
import TracePanel from "./detail/TracePanel";
import ContinuePanel from "./detail/ContinuePanel";
import DispatchSection from "./DispatchSection";
// Centre column for the task view: the conversation is the protagonist.
// Dispatch is kept here as the way a new conversation starts; it is not
// a stack of work cards. Trace timeline is above, continue input below.
// Dashboard conversation section: trace timeline above, continue input
// below. Dispatch lives in its own section above on the same page, so
// it is NOT duplicated here when no task is selected — only the hint.
export default function ConversationSection() {
const ctx = useConsole();
if (!ctx.currentTask) {
return (
<div className="conversation">
<DispatchSection />
<div className="muted">左侧点一条任务,对话就切到该任务;也可以先在上面派发一个新任务。</div>
<div className="muted">在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { useConsole } from "../context";
import { workerAgeText } from "../api/client";
// Dashboard data cards: computed ONLY from data the store already
// fetches (health, quota, tasks, workers, projects, envs, members,
// skills/apps/MCP). No new API endpoint, no new API call. A card never
// presents 0 as a real count when its dataset is not loaded or its
// fetch failed: it shows 未加载 / 先选项目 / 加载失败 plus the
// existing error string instead.
function Card({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div className="dash-card data-card">
<div className="dash-card-label">{label}</div>
<div className="dash-card-value">{value}</div>
<div className="muted">{sub}</div>
</div>
);
}
export default function DataCards() {
const ctx = useConsole();
const healthValue = ctx.healthText === "未加载" ? "未加载"
: ctx.healthText.includes("worker_ok=true") ? "正常"
: ctx.healthText.includes("worker_ok=false") ? "异常"
: ctx.healthText.includes("加载失败") ? "加载失败" : "未知";
const quotaMatch = ctx.quotaText.match(/剩余 \$([0-9.]+)/);
const quotaValue = ctx.quotaText.includes("加载失败") ? "加载失败"
: ctx.quotaText.includes("未填 key") || ctx.quotaText === "未加载" ? "未加载"
: quotaMatch ? "$" + quotaMatch[1] : "配额";
const statusCounts = (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)
.map((s) => ctx.tasks.filter((t) => t.status === s).length)
.map((n, i) => (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)[i] + " " + n)
.join(" | ");
const selectedProject = ctx.projects.find((p) => p.id === ctx.projectId);
const tasksCard = ctx.taskError
? { value: "加载失败", sub: ctx.taskError }
: !ctx.projectId
? { value: "先选项目", sub: ctx.projectMsg || "项目加载后自动选第一个" }
: { value: String(ctx.tasks.length), sub: ctx.taskCountText + " | " + statusCounts + (selectedProject ? " | " + selectedProject.name : "") };
let workersCard: { value: string; sub: string };
if (ctx.workersError) {
workersCard = { value: "加载失败", sub: ctx.workersError };
} else if (ctx.workers === null) {
workersCard = { value: ctx.workersSummary || "未加载", sub: "完整 worker 清单仅全局管理员可见" };
} else {
const online = ctx.workers.filter((w) => {
if (w.alive !== undefined && w.alive !== null) return !!w.alive;
const age = workerAgeText(w).age;
return age !== null && age <= 120;
}).length;
workersCard = { value: online + " 在线 / " + ctx.workers.length, sub: (ctx.workers.length - online) + " 离线" };
}
const projectsCard = ctx.projectMsg.includes("加载失败")
? { value: "加载失败", sub: ctx.projectMsg }
: { value: ctx.projects.length + " 个", sub: selectedProject ? "当前项目:" + selectedProject.name : "未选项目(加载后自动选第一个)" };
const projectErrors = [ctx.envMsg, ctx.memberError, ctx.skillError].filter((s) => s && s.includes("失败"));
const projectDataCard = !ctx.projectId
? { value: "先选项目", sub: "选项目后显示环境 / 成员 / 技能计数" }
: projectErrors.length
? { value: "部分加载失败", sub: projectErrors.join(" | ") }
: { value: ctx.envs.length + " 个环境", sub: ctx.members.length + " 成员 | " + ctx.skills.length + " 技能 | " + ctx.apps.length + " 应用 | " + ctx.mcpServers.length + " MCP" };
return (
<section className="dash-cards" aria-label="数据卡片">
<Card label="健康" value={healthValue} sub={ctx.healthText} />
<Card label="配额" value={quotaValue} sub={ctx.quotaText} />
<Card label="任务" value={tasksCard.value} sub={tasksCard.sub} />
<Card label="Workers" value={workersCard.value} sub={workersCard.sub} />
<Card label="项目" value={projectsCard.value} sub={projectsCard.sub} />
<Card label="项目数据" value={projectDataCard.value} sub={projectDataCard.sub} />
</section>
);
}
@@ -1,86 +0,0 @@
import { useConsole } from "../context";
import { ModuleIcon, type View } from "../components/IconRail";
import { shortId } from "../api/client";
import TaskDetailPanel from "./TaskDetailPanel";
import MembersPanel from "./MembersPanel";
import EnvContextPanel from "./EnvContextPanel";
import SkillsToolsPanel from "./SkillsToolsPanel";
import HealthPanel from "./HealthPanel";
import AutomationsSection from "./AutomationsSection";
import TriageSection from "./TriageSection";
// Right panel, modelled on the Muse screenshot: profile header with a
// real connection status (API key / health, never a fake user), an icon
// row that switches modules, a 今天 list of recent tasks, and below it
// the selected task's detail (or the current module's context) as a
// collapsible section. Nothing that was in the old right column is
// dropped: the detail/context panels are the same components, only
// rendered inside <details> under the today list.
export default function RightPanel({ view, onSelect, onClose }: {
view: View; onSelect: (v: View) => void; onClose: () => void;
}) {
const ctx = useConsole();
const connected = ctx.keySaved;
const today = ctx.tasks.slice().reverse().slice(0, 10);
const context = (() => {
switch (view) {
case "projects": return <MembersPanel />;
case "env": return <EnvContextPanel />;
case "skills": return <SkillsToolsPanel />;
case "workers": case "admin": case "key": return <HealthPanel />;
case "automations": return <TriageSection />;
case "triage": return <AutomationsSection />;
default: return <TaskDetailPanel />;
}
})();
const iconRow: Array<[View, string]> = [["task", "任务"], ["admin", "管理区"], ["automations", "自动化"], ["key", "API Key"]];
return (
<div>
<div className="right-profile">
<button type="button" className="icon-btn" aria-label="关闭右侧栏" onClick={onClose}>×</button>
<div className="right-avatar" aria-hidden="true">C</div>
<div className="right-name">Codex 命令台</div>
<div className={connected ? "right-status" : "right-status off"}>
{connected ? "● 已连接" : "○ 未连接(先在 API Key 里保存 key)"}
</div>
<div className="muted">{ctx.healthText}</div>
<div className="muted">{ctx.quotaText}</div>
<div className="muted">自动刷新 · 每 10 秒 · 已刷新 {ctx.refreshStamp}</div>
</div>
<div className="right-icon-row" aria-label="快捷方式">
{iconRow.map(([v, label]) => (
<button key={v} type="button" title={label} aria-label={label} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>
<ModuleIcon view={v} />
</button>
))}
</div>
<div>
<div className="list-section-head"><span>今天</span></div>
{today.length === 0 ? (
<div className="muted">今天暂无任务</div>
) : (
<ul className="today-list">
{today.map((t) => {
const done = t.status === "completed" || t.status === "merged";
const time = t.updated_at ? new Date(t.updated_at * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
return (
<li key={t.id} className="today-item" onClick={() => { ctx.showDetail(t.id); onSelect("task"); }}>
<span className={"today-check" + (done ? " done" : "")}>{done ? "✓" : ""}</span>
<span className="today-item-main">
<span className="today-item-title">{String(t.prompt || shortId(t.id)).slice(0, 60) || shortId(t.id)}</span>
<span className="today-item-sub">{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}</span>
</span>
<span className="today-item-time">{time}</span>
</li>
);
})}
</ul>
)}
</div>
<details className="right-detail" open>
<summary>{view === "task" ? "任务详情" : "模块详情"}</summary>
{context}
</details>
</div>
);
}
@@ -19,6 +19,12 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
</svg>
<input type="text" placeholder="搜索" aria-label="搜索任务" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
</div>
<div className="bar">
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
</select>
</div>
<div className="list-section-head">
<span>主要聊天</span>
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
+28 -57
View File
@@ -37,55 +37,28 @@ button.danger:hover{background:#fee4e2}
.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff;color:var(--text);font-size:16px;line-height:1}
.icon-btn:hover{background:#f9fafb}
/* App shell: icon rail + list + conversation + right panel, full viewport.
Columns are separated by hairlines, not gaps or cards. */
.muse-app{display:grid;grid-template-columns:56px 260px minmax(0,1fr) 300px;height:100vh;background:#fff}
.muse-app.no-right{grid-template-columns:56px 260px minmax(0,1fr)}
.icon-rail{border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0;overflow-y:auto}
.icon-rail button{width:40px;height:40px;border:0;border-radius:12px;background:transparent;display:grid;place-items:center;color:var(--text);padding:0;flex:none}
.icon-rail button:hover{background:#f2f2f2}
.icon-rail button.active{background:var(--accent-soft);color:var(--accent)}
.icon-rail svg{width:22px;height:22px}
.list-col{border-right:1px solid var(--line);overflow-y:auto;padding:16px 12px;min-width:0}
.search-box{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:999px;padding:8px 12px;background:#fff}
.search-box svg{flex:none;color:var(--muted)}
.search-box input{border:0;padding:0;flex:1;min-width:0;font-size:14px;background:transparent}
.search-box input:focus{box-shadow:none;outline:none}
.list-section-head{display:flex;align-items:center;justify-content:space-between;padding:16px 4px 8px;font-weight:600;font-size:14px}
.list-section-head button{width:28px;height:28px;padding:0;display:grid;place-items:center;border-radius:50%}
.muse-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
.muse-item{padding:10px 12px;border-radius:12px;cursor:pointer;overflow:hidden}
.muse-item:hover{background:#f9fafb}
.muse-item.selected{background:#f2f2f2}
.muse-item-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:14px}
.muse-item-sub{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:12px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.task-dot{width:8px;height:8px;border-radius:50%;background:currentColor;flex:none}
.center-col{min-width:0;display:flex;flex-direction:column;overflow-y:auto;background:#fff}
.center-topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#fff;border-bottom:1px solid var(--line)}
.center-inner{max-width:760px;margin:0 auto;width:100%;padding:24px 20px 16px;display:flex;flex-direction:column;gap:20px;flex:1}
.right-col{border-left:1px solid var(--line);overflow-y:auto;padding:20px 16px;display:flex;flex-direction:column;gap:20px;min-width:0}
.right-profile{text-align:center;position:relative}
.right-profile .icon-btn{position:absolute;top:0;right:0}
.right-avatar{width:72px;height:72px;border-radius:50%;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:28px;font-weight:700;margin:0 auto}
.right-name{font-weight:700;font-size:17px;margin-top:10px}
.right-status{color:var(--green);font-size:13px;font-weight:600}
.right-status.off{color:var(--muted)}
.right-icon-row{display:flex;gap:4px;justify-content:space-between;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px 4px}
.right-icon-row button{width:36px;height:36px;border:0;border-radius:12px;background:transparent;display:grid;place-items:center;color:var(--text);padding:0}
.right-icon-row button:hover{background:#f2f2f2}
.right-icon-row button.active{background:var(--accent-soft);color:var(--accent)}
.right-icon-row svg{width:20px;height:20px}
.today-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
.today-item{display:flex;gap:10px;cursor:pointer;align-items:flex-start}
.today-check{width:22px;height:22px;border-radius:50%;border:1.5px solid var(--line-strong);display:grid;place-items:center;flex:none;font-size:13px;color:var(--muted)}
.today-check.done{border-color:var(--green);background:var(--green);color:#fff}
.today-item-main{flex:1;min-width:0;display:block}
.today-item-title{display:block;font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.today-item-sub{display:block;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.today-item-time{color:var(--muted);font-size:12px;flex:none;white-space:nowrap}
.right-detail summary{font-weight:600;font-size:14px;cursor:pointer;padding:8px 0}
.right-detail .card{border:0;padding:0;box-shadow:none}
.right-detail .card h2{font-size:16px}
/* Dashboard shell: ONE scrollable console page, same Muse tokens.
Slim sticky top bar (name + status + health + quota + refresh
stamp + anchor nav), a data-cards row, then every module as a
section. No icon-rail column, no left list column, no right
panel column — the old shell classes are gone with them. */
.dash-topbar{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);padding:10px 20px 8px}
.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.dash-brand{font-weight:700;font-size:17px}
.dash-status{font-size:13px;font-weight:600;color:var(--muted)}
.dash-status.ok{color:var(--green)}
.dash-stamp{margin-left:auto}
.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
.dash-nav a{color:var(--text);text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:999px}
.dash-nav a:hover{background:#f2f2f2}
.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}
.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:20px}
.dash-section.card{padding:20px}
.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;min-width:0}
.dash-card-label{color:var(--muted);font-size:13px;font-weight:600}
.dash-card-value{font-size:28px;font-weight:700;line-height:1.3;overflow-wrap:anywhere}
/* Cards: flat white sections with a hairline border, no shadow. */
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:20px}
@@ -158,19 +131,17 @@ pre{background:#f9fafb;color:var(--text);border:1px solid var(--line);border-rad
.chat-send{width:36px;height:36px;border-radius:50%;background:var(--bubble-user);border:0;color:var(--accent-dark);padding:0;display:grid;place-items:center;font-size:18px;line-height:1;flex:none}
.chat-send:disabled{opacity:.45;background:#f2f2f2;color:var(--muted)}
.conv-divider{border-top:1px solid var(--line);margin:4px 0}
@media (max-width:1240px){
.muse-app{grid-template-columns:56px 260px minmax(0,1fr)}
.muse-app.no-right{grid-template-columns:56px 260px minmax(0,1fr)}
.right-col{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line)}
}
@media (max-width:900px){
.muse-app,.muse-app.no-right{grid-template-columns:1fr}
.icon-rail{display:none}
.center-inner{padding:16px 14px}
.dash-cards{grid-template-columns:repeat(2,1fr)}
.dash-hide-sm{display:none}
.dash-page{padding:14px}
.dash-section.card{padding:16px}
.card{padding:16px}
th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3),th:nth-child(7),td:nth-child(7){display:none}
}
@media (max-width:560px){
.dash-cards{grid-template-columns:1fr}
.dash-nav{flex-wrap:nowrap;overflow-x:auto}
.card{padding:16px}
th:nth-child(6),td:nth-child(6),th:nth-child(9),td:nth-child(9){display:none}
.bar > input[type="text"],.bar > input[type="password"],.bar > select{flex:1 1 100%}