console: 项目导航移到左侧栏,工作台不再承担项目模块

左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色,
选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url)
+ 选中项目成员(邀请/改角色/新增移除);工作台删除
sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一
项目选择器,自动选第一个项目逻辑不变。
薄模块补齐(均为已核实后端路由):成员移除 DELETE、
自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进
任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额
POST。主线新功能(Todos、Browser、taskMeta 等)原样保留,
未做任何回退。console 标记单测本地 53 项通过。
This commit is contained in:
host committed 2026-10-30 07:43:54 +00:00
1 parent d6369154c2
commit 08484302ac
16 files changed
+293 -100

No files matched your search

+9 -6
View File
@@ -9,8 +9,7 @@ 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 ProjectNav from "./sections/ProjectNav";
import EnvSection from "./sections/EnvSection";
import EnvContextPanel from "./sections/EnvContextPanel";
import WorkersSection from "./sections/WorkersSection";
@@ -25,7 +24,10 @@ import TodosSection from "./sections/TodosSection";
// data-cards row on top of a long stacked page, no detail stack
// hanging under the conversation. The new shell is an IDE-style
// workspace that never scrolls as one page:
// 左侧栏:导航(按工作流分组)+ 数据卡片(紧凑版,始终可见)
// 左侧栏:导航(按工作流分组)+ 项目模块(ProjectNav:项目列表 +
// 创建项目 + 成员,id nav-projects,是全站唯一的项目选择器;
// 因此不再有独立的“项目与成员”视图,旧 ProjectsSection 已删,
// 工作台也不再带项目选择器)+ 数据卡片(紧凑版,始终可见)
// 任务工作台:左 = 任务列表轨(搜索/筛选/分页),中 = 派发 + 对话,
// 右 = 任务详情检查器(概览/协作/评审与审批/线程四个内页签,
// 见 TaskDetailPanel)
@@ -36,11 +38,10 @@ import TodosSection from "./sections/TodosSection";
// unmounted with a view. The view is synced to location.hash
// (#view-tasks etc.) so a reload or a shared link lands on the
// same view; unknown hashes fall back to the task workspace.
type ViewId = "tasks" | "todos" | "projects" | "resources" | "automation" | "admin";
type ViewId = "tasks" | "todos" | "resources" | "automation" | "admin";
const VIEWS: Array<[ViewId, string, string]> = [
["tasks", "任务工作台", "任务"],
["todos", "项目待办", "任务"],
["projects", "项目与成员", "管理"],
["resources", "环境与 Workers", "管理"],
["automation", "技能与自动化", "管理"],
["admin", "系统与 Key", "管理"],
@@ -67,6 +68,9 @@ function SideNav({ view, setView }: { view: ViewId; setView: (v: ViewId) => void
</div>
))}
</nav>
<div id="nav-projects" className="shell-project-nav">
<ProjectNav />
</div>
<div className="shell-stats">
<div className="shell-stats-label">概览</div>
<DataCards />
@@ -138,7 +142,6 @@ export default function App() {
) : (
<main className="page">
{view === "todos" ? (<><TodosSection /></>)
: view === "projects" ? (<><ProjectsSection /><MembersPanel /></>)
: view === "resources" ? (<><EnvSection /><EnvContextPanel /><WorkersSection /><HealthPanel /></>)
: view === "automation" ? (<><SkillsSection /><SkillsToolsPanel /><AutomationsSection /><TriageSection /></>)
: (<><AdminSection /><ApiKeySection /></>)}
+1
View File
@@ -93,6 +93,7 @@ export function memberErr(e: Error): string {
if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框";
if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
if (m.includes("cannot remove the only owner")) return "不能移除唯一 owner:请先把另一位成员升为 owner";
if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确";
if (m.includes("member not found")) return "该用户不是本项目成员";
if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner";
+4 -2
View File
@@ -36,7 +36,9 @@ export interface Followup { mode?: string; status?: string; sender_task_id?: str
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null }
export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; created_at?: number; cancelled_at?: number; run_once?: boolean }
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; last_claimed_at?: number; created_by?: string; created_at?: number; cancelled_at?: number; first_run_at?: number; run_once?: boolean; project_id?: string }
export interface AutomationRun { id?: string; automation_id?: string; project_id?: string; task_id?: string; status?: string; detail?: string; created_at?: number }
export interface AutomationDetail extends Automation { runs?: AutomationRun[]; runs_note?: string; heartbeat_binding?: unknown }
export interface Member { user_id: string; username?: string; role?: string }
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
@@ -45,7 +47,7 @@ export interface Agent { kind?: string; name?: string; status?: string; prompt?:
export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number }
export interface Triage {
counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number };
automations_due?: Array<{ name?: string; interval_seconds?: number }>;
automations_due?: Array<{ id?: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }>;
failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>;
pending_followups?: Array<{ task_id?: string; body?: string }>;
recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>;
+4 -4
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
import type { Automation, AutomationDetail, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
export interface ConsoleCtx {
keySaved: boolean; keyState: string;
@@ -7,13 +7,13 @@ export interface ConsoleCtx {
quotaText: string;
healthText: string; refreshHealth: () => void;
adminGate: string; adminBody: boolean; adminResult: string; adminUserId: string; setAdminUserId: (v: string) => void;
adminQuota: () => void; adminSweep: () => void; adminBackup: () => void;
adminQuota: () => void; adminSetQuota: (uid: string, usdText: string) => void; adminSweep: () => void; adminBackup: () => void;
projects: Project[]; projectId: string; projectMsg: string;
selectProject: (id: string) => void; refreshProjects: () => void;
createProject: (name: string, repo: string) => Promise<void>;
githubRepos: Array<{ clone_url: string; repo_full_name: string }>;
workers: Worker[] | null; workersSummary: string; workersError: string; refreshWorkers: () => void;
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>;
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>; removeMember: (uid: string) => void;
skills: SkillItem[]; apps: SkillItem[]; mcpServers: McpServer[]; skillMsg: string; skillError: string; mcpMsg: string; mcpOauthMsg: string; mcpOauthBox: string;
refreshSkills: () => void; reloadMcp: () => void; mcpOauth: (name: string) => Promise<void>;
envs: Env[]; envCount: string; envMsg: string; envRepoLine: string; envRepoBar: boolean; envRepoMsg: string; envSelect: string; setEnvSelect: (v: string) => void;
@@ -35,7 +35,7 @@ export interface ConsoleCtx {
followups: Followup[];
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void;
automations: Automation[]; autoMsg: string; autoDetail: AutomationDetail | null; autoDetailMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void; loadAutomationDetail: (id: string) => void; closeAutomationDetail: () => void;
triage: Triage | null; triageError: string; refreshTriage: () => void;
threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void;
dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string;
+30 -1
View File
@@ -253,6 +253,17 @@ export function useCatalog() {
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, []);
const removeMember = useCallback(async (uid: string) => {
const pid = projectIdRef.current;
if (!pid) return;
if (!window.confirm("确定移除该成员吗?移除后其项目权限立即失效(唯一 owner 不可移除,服务端会拒绝)。")) return;
try {
await api(paths.projectMember(pid, uid), "DELETE");
setMemberMsg("成员已移除");
await refreshMembers(pid);
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, [refreshMembers]);
const reloadMcp = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) { setMcpMsg("先选项目"); return; }
@@ -289,6 +300,24 @@ export function useCatalog() {
} catch (e: any) { setAdminResult(adminErr(e)); }
}, [adminUserId]);
const adminSetQuota = useCallback(async (uid: string, usdText: string) => {
const target = uid.trim();
if (!target) { setAdminResult("请填要设置配额的用户 id"); return; }
// POST /admin/quotas {user_id, quota_usd_micros}: integer micros or
// null for unlimited, >= 0. The console takes dollars and converts.
let micros: number | null;
if (!usdText.trim()) micros = null;
else {
const usd = Number(usdText.trim());
if (!Number.isFinite(usd) || usd < 0) { setAdminResult("配额金额无效:请输入 >= 0 的美元数,留空表示不限额"); return; }
micros = Math.round(usd * 1000000);
}
try {
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotas, "POST", { user_id: target, quota_usd_micros: micros });
setAdminResult("配额已设置:用户 " + target.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
const adminSweep = useCallback(async () => {
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
try {
@@ -314,6 +343,6 @@ export function useCatalog() {
models, modelCustomVisible,
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup,
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, removeMember, reloadMcp, mcpOauth, adminQuota, adminSetQuota, adminSweep, adminBackup,
};
}
@@ -108,14 +108,14 @@ export function useConsoleStore(): ConsoleCtx {
healthText: catalog.healthText, refreshHealth: catalog.refreshHealth,
adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult,
adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId,
adminQuota: catalog.adminQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
adminQuota: catalog.adminQuota, adminSetQuota: catalog.adminSetQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg,
selectProject, refreshProjects: catalog.refreshProjects,
createProject: catalog.createProject,
githubRepos: catalog.githubRepos,
workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers,
members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError,
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole,
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole, removeMember: catalog.removeMember,
skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError,
mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox,
refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth,
@@ -144,7 +144,7 @@ export function useConsoleStore(): ConsoleCtx {
followups: detail.followups,
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation,
automations: tools.automations, autoMsg: tools.autoMsg, autoDetail: tools.autoDetail, autoDetailMsg: tools.autoDetailMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation, loadAutomationDetail: tools.loadAutomationDetail, closeAutomationDetail: tools.closeAutomationDetail,
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
dispatch: async (body, atts) => {
@@ -1,13 +1,15 @@
import { useCallback, useState } from "react";
import { api } from "../api/client";
import { paths } from "../api/paths";
import type { Automation, Triage } from "../api/types";
import type { Automation, AutomationDetail, Triage } from "../api/types";
// Project-scoped tools shown in the centre for their own views:
// automations (create/list/cancel) and the triage inbox (4 groups).
export function useProjectTools(getProjectId: () => string) {
const [automations, setAutomations] = useState<Automation[]>([]);
const [autoMsg, setAutoMsg] = useState("");
const [autoDetail, setAutoDetail] = useState<AutomationDetail | null>(null);
const [autoDetailMsg, setAutoDetailMsg] = useState("");
const [triage, setTriage] = useState<Triage | null>(null);
const [triageError, setTriageError] = useState("");
@@ -66,10 +68,26 @@ export function useProjectTools(getProjectId: () => string) {
const pid = getProjectId();
if (!pid) return;
void api(paths.projectAutomationCancel(pid, id), "POST", {})
.then(() => { setAutoMsg("自动化已取消"); void refreshAutomations(pid); })
.then(() => { setAutoMsg("自动化已取消"); setAutoDetail((prev) => (prev && prev.id === id ? null : prev)); void refreshAutomations(pid); })
.catch((e: any) => setAutoMsg("取消失败:" + e.message));
}, [getProjectId, refreshAutomations]);
// GET /projects/{pid}/automations/{aid}: full definition + recent
// runs (+ runs_note, heartbeat_binding). Verified against origin/main
// cloud/server.py _automation_get / cloud/automations.py get_automation.
const loadAutomationDetail = useCallback(async (id: string) => {
const pid = getProjectId();
if (!pid) { setAutoDetailMsg("先选项目"); return; }
try {
const d = await api<AutomationDetail>(paths.projectAutomation(pid, id));
setAutoDetail(d);
setAutoDetailMsg("");
} catch (e: any) { setAutoDetailMsg("详情加载失败:" + e.message); }
}, [getProjectId]);
const closeAutomationDetail = useCallback(() => { setAutoDetail(null); setAutoDetailMsg(""); }, []);
const refreshTriage = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : getProjectId();
if (!id) { setTriage(null); return; }
@@ -80,5 +98,5 @@ export function useProjectTools(getProjectId: () => string) {
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
}, [getProjectId]);
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, refreshTriage };
return { automations, autoMsg, autoDetail, autoDetailMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, loadAutomationDetail, closeAutomationDetail, refreshTriage };
}
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
export default function AdminSection() {
const ctx = useConsole();
const [setUser, setSetUser] = useState("");
const [setUsd, setSetUsd] = useState("");
return (
<Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}>
<div className="muted">{ctx.adminGate}</div>
@@ -12,6 +15,11 @@ export default function AdminSection() {
<input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} />
<button onClick={ctx.adminQuota}>查该用户配额</button>
</Bar>
<Bar>
<input type="text" placeholder="要设置配额的用户 id" value={setUser} onChange={(e) => setSetUser(e.target.value)} />
<input type="text" placeholder="美元配额(留空 = 不限额)" value={setUsd} onChange={(e) => setSetUsd(e.target.value)} />
<button className="primary" onClick={() => ctx.adminSetQuota(setUser, setUsd)}>设置配额</button>
</Bar>
<Bar>
<button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button>
<button onClick={ctx.adminBackup}>手动触发备份</button>
@@ -37,6 +37,7 @@ export default function MembersPanel() {
<select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button onClick={() => void ctx.removeMember(m.user_id)}>移除</button>
</li>
);
})}
@@ -0,0 +1,51 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { shortId } from "../api/client";
import MembersPanel from "./MembersPanel";
// Left sidebar: the one project selector for the whole console.
// Lists every project (name + short id + my role), click selects it;
// below the list is the create-project form (same fields as the old
// workbench ProjectsSection: name, GitHub repo select, repo_url) and
// the selected project's members. The workbench no longer carries any
// project-module UI; it only consumes ctx.projectId.
export default function ProjectNav() {
const ctx = useConsole();
const [name, setName] = useState("");
const [repoSelect, setRepoSelect] = useState("");
const [repo, setRepo] = useState("");
return (
<div className="project-nav">
<Card title="项目">
<Bar><button onClick={ctx.refreshProjects}>刷新项目</button></Bar>
{ctx.projects.length === 0 ? <div className="muted">暂无项目</div> : (
<ul className="project-nav-list">
{ctx.projects.map((p) => (
<li key={p.id} className={"project-nav-item" + (p.id === ctx.projectId ? " selected" : "")} onClick={() => ctx.selectProject(p.id)} title={p.id}>
<span className="project-nav-name">{p.name}</span>
<span className="project-nav-meta">{shortId(p.id)}{p.my_role ? " · " + p.my_role : ""}</span>
</li>
))}
</ul>
)}
<h3>创建项目</h3>
<Bar>
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
</Bar>
<Bar>
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
</Bar>
<Bar>
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
<button className="primary" onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>创建项目</button>
</Bar>
<span className="muted">{ctx.projectMsg}</span>
</Card>
<MembersPanel />
</div>
);
}
@@ -1,32 +0,0 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { shortId } from "../api/client";
export default function ProjectsSection() {
const ctx = useConsole();
const [name, setName] = useState("");
const [repoSelect, setRepoSelect] = useState("");
const [repo, setRepo] = useState("");
return (
<Card id="nav-projects" title="项目">
<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>
<button onClick={ctx.refreshProjects}>刷新</button>
</Bar>
<Bar>
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
<button onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>新建项目</button>
</Bar>
<span className="muted">{ctx.projectMsg}</span>
</Card>
);
}
@@ -5,9 +5,12 @@ import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
// 与任务详情共用同一口径。
// GitHub issues-style task list: search on top, the project selector
// (the auto-project fix — do not remove it), a 主要聊天 header with a
// + button, then hairline rows with a Primer status dot at the left,
// GitHub issues-style task list: search on top, a 主要聊天 header with
// a + button, then hairline rows with a Primer status dot at the
// left. There is deliberately NO project selector here any more:
// the left sidebar (ProjectNav) is the one project selector, and the
// auto-select-first-project fix in the store keeps the list from
// ever starting empty.
// a 14px title and a 12px muted meta line. The search input IS the
// existing task search (id/prompt); status and executor filters stay
// as a compact row below it. The selected row gets the light
@@ -22,12 +25,6 @@ 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>
@@ -1,22 +1,52 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client";
// Workers are read-only by design (GET /workers + worker-side
// heartbeat only — no start/stop/kill actions exist to surface), so
// production-grade here is list + status + refresh + frontend-only
// filtering of the already-fetched list. Non-admin users only get
// the online/offline aggregate from the backend (see _workers_list),
// so search/filter apply to the full list only and say so.
export default function WorkersSection() {
const ctx = useConsole();
const [query, setQuery] = useState("");
const [onlineFilter, setOnlineFilter] = useState<"all" | "online" | "offline">("all");
const isAlive = (w: { alive?: boolean | null; last_seen?: number; last_seen_seconds_ago?: number }) => {
const info = workerAgeText(w);
return w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
};
const q = query.trim().toLowerCase();
const filtered = (ctx.workers || []).filter((w) => {
if (q && !(((w.worker_id || w.id || "") + " " + (w.executor || "")).toLowerCase().includes(q))) return false;
if (onlineFilter === "online" && !isAlive(w)) return false;
if (onlineFilter === "offline" && isAlive(w)) return false;
return true;
});
return (
<Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
<Bar>
<input type="text" placeholder="搜索 worker_id / 执行器" aria-label="搜索 Worker" value={query} onChange={(e) => setQuery(e.target.value)} />
<select aria-label="在线状态筛选" value={onlineFilter} onChange={(e) => setOnlineFilter(e.target.value as "all" | "online" | "offline")}>
<option value="all">在线状态:全部</option>
<option value="online">仅在线</option>
<option value="offline">仅离线</option>
</select>
{ctx.workers ? <span className="muted">显示 {filtered.length} / 共 {ctx.workers.length} 个</span> : null}
</Bar>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)</div>
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
: filtered.length === 0 ? <div className="muted">没有符合筛选条件的 worker</div>
: (
<DataListRaw>
{ctx.workers.map((w, i) => {
{filtered.map((w, i) => {
const run = w.running === undefined || w.running === null ? "?" : w.running;
const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity;
const info = workerAgeText(w);
const alive = w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
const alive = isAlive(w);
const live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
return (
<li key={i} className={alive ? "is-online" : "is-offline"}>
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useConsole } from "../../context";
import { Bar, DataList } from "../../components/ui";
import { fmt, shortId } from "../../api/client";
import type { Automation } from "../../api/types";
import type { Automation, AutomationDetail } from "../../api/types";
const PROMPT_PREVIEW = 160;
@@ -14,6 +14,49 @@ function humanInterval(s?: number): string {
return "";
}
// Automation detail (read-only display): backed by GET
// /projects/{pid}/automations/{aid} — full definition + runs[] +
// runs_note + heartbeat_binding. Editing is NOT duplicated here:
// the single edit form is main's inline edit in the list below
// (updateAutomation with the (a, name, prompt, interval, repo)
// signature). Detail display + runs + task buttons are our side's
// addition; cross-view jumps use location.hash because the IDE
// shell switches views on hashchange (no sec-conv / sec-automations
// element to scroll to).
function AutomationDetailBox({ detail }: { detail: AutomationDetail }) {
const ctx = useConsole();
const openTask = (id?: string) => {
if (!id) return;
ctx.showDetail(id);
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
};
return (
<div className="card">
<h2>自动化详情:{detail.name}</h2>
<div className="muted">
状态 {detail.status}{detail.due ? "|到期" : ""} | 每 {detail.interval_seconds} 秒 | 下次 {fmt(detail.next_run_at)}
| 上次执行 {fmt(detail.last_run_at)} | 创建人 {detail.created_by ? shortId(detail.created_by) : ""} | 创建于 {fmt(detail.created_at)}
{detail.cancelled_at ? " | 已取消于 " + fmt(detail.cancelled_at) : ""}
{detail.heartbeat_binding ? " | 心跳绑定:已绑定" : ""}
</div>
<div className="muted" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}>{detail.prompt || ""}</div>
{detail.repo_url ? <div className="muted">仓库 {detail.repo_url}</div> : null}
<Bar>
<button onClick={ctx.closeAutomationDetail}>关闭详情</button>
<span className="muted">编辑请在下面列表里点“编辑”。{ctx.autoDetailMsg}</span>
</Bar>
<div className="data-group"><b>最近产出({(detail.runs || []).length})</b>
<DataList empty="(无)" items={(detail.runs || []).map((r, i) => (
<span key={i} className="row-main">
{r.status}{r.created_at ? "|" + fmt(r.created_at) : ""}{r.task_id ? <> | 任务 <button onClick={() => openTask(r.task_id as string)}>{shortId(r.task_id)}</button></> : ""}{r.detail ? "|" + String(r.detail).slice(0, 120) : ""}
</span>
))} />
</div>
{detail.runs_note ? <div className="muted">{detail.runs_note}</div> : null}
</div>
);
}
export function AutomationPanel() {
const ctx = useConsole();
const [name, setName] = useState("");
@@ -53,6 +96,7 @@ export function AutomationPanel() {
<div key={a.id}>
<div className="row-main">
[{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)}
<button onClick={() => void ctx.loadAutomationDetail(a.id)}>详情</button>
{a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
</div>
{a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null}
@@ -80,6 +124,8 @@ export function AutomationPanel() {
</div>
);
})} />
{ctx.autoDetailMsg && !ctx.autoDetail ? <div className="muted">{ctx.autoDetailMsg}</div> : null}
{ctx.autoDetail ? <AutomationDetailBox key={ctx.autoDetail.id} detail={ctx.autoDetail} /> : null}
</>
);
}
@@ -87,21 +133,45 @@ export function AutomationPanel() {
export function TriagePanel() {
const ctx = useConsole();
const t = ctx.triage;
// Clicking an actionable item opens that task's detail (tasks view)
// or the automation's detail (automation view). Field names are
// exactly the triage payload's: failed_tasks[].id, pending_followups
// [].task_id, recent_runs[].task_id (nullable), automations_due[].id.
// Jumps use location.hash because the IDE shell switches views on
// hashchange; there is no sec-conv / sec-automations to scroll to.
const openTask = (id?: string) => {
if (!id) return;
ctx.showDetail(id);
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
};
const openAutomation = (id?: string) => {
if (!id) return;
void ctx.loadAutomationDetail(id);
try { window.location.hash = "#view-automation"; } catch { /* noop */ }
};
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
if (!t) return <div className="muted">(先选项目)</div>;
return (
<>
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => <span key={i} className="row-main">{a.name}|每 {a.interval_seconds} 秒</span>)} />
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => (
<span key={i} className="row-main">{a.id ? <button onClick={() => openAutomation(a.id)}>{a.name}|每 {a.interval_seconds} 秒</button> : <>{a.name}|每 {a.interval_seconds} 秒</>}</span>
))} />
</div>
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => <span key={i} className="row-main">{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</span>)} />
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => (
<span key={i} className="row-main"><button onClick={() => openTask(x.id)}>{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</button></span>
))} />
</div>
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => <span key={i} className="row-main">任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</span>)} />
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => (
<span key={i} className="row-main"><button onClick={() => openTask(f.task_id)}>任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</button></span>
))} />
</div>
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => <span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? "|任务 " + shortId(r.task_id) : ""}{r.task_status ? "|" + r.task_status : ""}</span>)} />
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => (
<span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? <>|任务 <button onClick={() => openTask(r.task_id)}>{shortId(r.task_id)}</button></> : ""}{r.task_status ? "|" + r.task_status : ""}</span>
))} />
</div>
</>
);
+16 -1
View File
@@ -254,7 +254,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
which is still tight — just in a different order. */
.dash-col{display:contents}
#sec-dispatch{order:1} #sec-tasks{order:2} #sec-conv{order:3}
#sec-projects{order:4} #sec-env{order:5} #sec-workers{order:6}
#sec-env{order:5} #sec-workers{order:6}
#sec-skills{order:7} #sec-automations{order:8} #sec-triage{order:9}
#sec-admin{order:10} #sec-key{order:11}
.dash-cards{grid-template-columns:repeat(2,1fr)}
@@ -285,6 +285,20 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
.shell-nav button{text-align:left;border:0;box-shadow:none;background:transparent;padding:8px;border-radius:var(--radius);font-weight:500}
.shell-nav button:hover{background:#fff}
.shell-nav button.active{background:var(--text);color:#fff}
/* Project sidebar (ProjectNav): the one project selector for the whole
console — project list + create + members — living in the IDE
shell's left sidebar. Same Primer tokens as the rest of the shell;
the sidebar is 260px wide, so its bars stack full-width. */
.shell-project-nav{padding:0 12px 16px;min-width:0}
.shell-project-nav .project-nav{display:flex;flex-direction:column;gap:16px;min-width:0}
.shell-project-nav .project-nav-list{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.shell-project-nav .project-nav-item{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--line);background:#fff}
.shell-project-nav .project-nav-item:last-child{border-bottom:0}
.shell-project-nav .project-nav-item:hover{background:var(--subtle-bg)}
.shell-project-nav .project-nav-item.selected{background:var(--selected-bg)}
.shell-project-nav .project-nav-name{display:block;font-size:14px;font-weight:600;overflow-wrap:anywhere}
.shell-project-nav .project-nav-meta{display:block;font-size:12px;color:var(--muted)}
.shell-project-nav .bar > input[type="text"],.shell-project-nav .bar > select{flex:1 1 100%}
.shell-stats{margin-top:auto;border-top:1px solid var(--line);padding:16px 12px}
.shell-stats-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 4px 8px}
.shell-stats .dash-cards{display:flex;flex-direction:column;gap:8px}
@@ -316,6 +330,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
.shell-side{flex-direction:row;flex-wrap:wrap;align-items:flex-start;border-right:0;border-bottom:1px solid var(--line);overflow:visible}
.shell-nav{flex-direction:row;flex-wrap:wrap}
.shell-nav-group{flex-direction:row;align-items:center}
.shell-project-nav{flex:1 1 100%}
.shell-stats{display:none}
.shell-hide-sm{display:none}
.workspace{grid-template-columns:1fr}