From e5b6c45c835995fd016333eb4fbff1121ab35e1a Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 02:25:48 +0800 Subject: [PATCH] =?UTF-8?q?console:=20=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5=20+=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=88=86=E9=A1=B5=E9=9D=A2=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务列表分页(服务端 + 前端): - GET /tasks 新增 limit(1..100,非法回 400)、offset、 order=desc、status/executor/q 服务端过滤;回 total(过滤后 计数)与 status_counts(全项目统计,不受过滤与分页影响, 数据卡片不再把当前页当成全项目);无 limit 时旧行为不变 (全量、正序,协调子任务与旧调用不受影响)。 - 前端每页 20 条、最新在前,上一页/下一页 + 页码;筛选与 搜索改服务端过滤(搜索防抖 300ms),切筛选/切项目/派发 后回第 1 页,10 秒刷新与刷新按钮停在当前页;新测试 test_task_list_pagination.py 5 项。 分页面签: - 顶栏锚点导航改成 5 个页签:工作台(派发 + 任务列表 + 对话 + 详情)、项目(项目 + 成员)、环境与 Workers、 技能与自动化(技能 + 自动化 + 分诊)、管理与 Key; 数据卡片仍在页签上方常驻。选中任务存在 App 层 store 里,切页不丢,回来仍是同一任务、10 秒刷新续同步; 页签同步到 location.hash(#tab-workbench 等),刷新与 分享链接落在同一页签;窄屏工作台仍保持派发在前的顺序。 - 工作台页签仍是左(任务)右(派发/对话/详情)两栏, 沿用等高内部滚动规则;其它页签单列堆叠。 --- cloud/console-react/src/App.tsx | 182 +++++++++++------- cloud/console-react/src/api/paths.ts | 7 + cloud/console-react/src/context.ts | 1 + .../src/hooks/useConsoleStore.ts | 6 +- cloud/console-react/src/hooks/useTaskList.ts | 91 +++++++-- .../console-react/src/sections/DataCards.tsx | 8 +- .../src/sections/TaskListSection.tsx | 9 +- cloud/console-react/src/tokens.css | 22 +++ cloud/console.html | 44 ++--- cloud/server.py | 60 +++++- cloud/tests/test_task_list_pagination.py | 135 +++++++++++++ 11 files changed, 443 insertions(+), 122 deletions(-) create mode 100644 cloud/tests/test_task_list_pagination.py diff --git a/cloud/console-react/src/App.tsx b/cloud/console-react/src/App.tsx index d44afec..7c6f4bc 100644 --- a/cloud/console-react/src/App.tsx +++ b/cloud/console-react/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { ConsoleContext, useConsole } from "./context"; import { useConsoleStore } from "./hooks/useConsoleStore"; import { useAutoRefresh, useIntervalMs } from "./hooks/useAutoRefresh"; @@ -19,30 +20,36 @@ import SkillsToolsPanel from "./sections/SkillsToolsPanel"; import AutomationsSection from "./sections/AutomationsSection"; import TriageSection from "./sections/TriageSection"; -// Layout only: ONE scrollable console page in GitHub Primer style — -// a dark header bar (name + real connection status + health + quota -// + refresh stamp + anchor nav as header links), a data-cards row, -// then every module as a bordered section. At medium/wide widths the -// sections live in three independent column wrappers (left: tasks + -// projects; middle: dispatch + conversation; right: environments + -// Workers + skills + automations + triage + admin + API key) so each -// column's modules stack tightly on their own, instead of sharing -// grid rows with the other columns — shared rows were the blank-gap -// bug (a tall task list in row 2 held the whole row tall, so the -// conversation in row 3 started far below a short dispatch box). -// At narrow width the wrappers use display:contents plus an explicit -// order per section, so the single column stacks in the original -// DOM order (dispatch, tasks, conversation, projects, …), not in -// column-group order. Detail/context panels sit under their own -// sections below (detail under 对话, members under 项目, and so on). -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"], +// Layout: GitHub Primer style, data cards on top, then TAB pages +// instead of one endless page. The old layout stacked 11 sections +// (dispatch, tasks, conversation, projects, env, workers, skills, +// automations, triage, admin, API Key, plus detail panels) on one +// page; tabbing is what actually cuts the visible length, while +// list pagination (see useTaskList) cuts the data fetched per page. +// Tabs: +// 工作台:派发 + 任务列表(分页)+ 对话 + 任务详情 +// 项目:项目 + 成员 +// 环境与 Workers:环境 + 环境上下文 + Workers + 健康 +// 技能与自动化:技能 + 技能工具 + 自动化 + 分诊 +// 管理与 Key:管理区 + API Key +// Selected task survives tab switches: the store (useConsoleStore) +// lives in App, above the tabs, so currentTask / detail / chat +// state is not unmounted with a tab — switching back shows the same +// task, and the 10 s refresh re-syncs its detail lists. The tab is +// synced to location.hash (#tab-workbench etc.) so a reload or a +// shared link lands on the same tab; unknown hashes fall back to +// the workbench. +type TabId = "workbench" | "projects" | "env" | "skills" | "admin"; +const TABS: Array<[TabId, string]> = [ + ["workbench", "工作台"], ["projects", "项目"], ["env", "环境与 Workers"], + ["skills", "技能与自动化"], ["admin", "管理与 Key"], ]; +function tabFromHash(): TabId { + const h = window.location.hash.replace("#tab-", ""); + return (TABS.some(([id]) => id === h) ? h : "workbench") as TabId; +} -function TopBar() { +function TopBar({ tab, setTab }: { tab: TabId; setTab: (t: TabId) => void }) { return (
@@ -52,8 +59,10 @@ function TopBar() {
-
); @@ -78,63 +87,88 @@ export default function App() { const store = useConsoleStore(); useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000); useIntervalMs(() => store.refreshSlow(), 60000); + const [tab, setTabState] = useState(() => tabFromHash()); + const setTab = (t: TabId) => { + setTabState(t); + try { window.history.replaceState(null, "", "#tab-" + t); } catch { /* noop */ } + }; + useEffect(() => { + const onHash = () => setTabState(tabFromHash()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); const scrollToDispatch = () => { - document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" }); + setTab("workbench"); + setTimeout(() => document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" }), 0); }; return ( - -
+ +
-
-
-

任务列表

- -
-
- - -
-
-
-
- -
-
-
-

对话

- + {tab === "workbench" ? ( +
+
+
+

任务列表

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

对话

+ +
+ +
+
+ + ) : tab === "projects" ? ( +
+
+ + +
+
+ ) : tab === "env" ? ( +
+
+ + +
+
+ + +
+
+ ) : tab === "skills" ? ( +
+
+ + +
+
+ +
+
+ +
+
+ ) : ( +
+
+ +
+
+ +
+
+ )}
); diff --git a/cloud/console-react/src/api/paths.ts b/cloud/console-react/src/api/paths.ts index 479d526..da7edc9 100644 --- a/cloud/console-react/src/api/paths.ts +++ b/cloud/console-react/src/api/paths.ts @@ -26,6 +26,13 @@ export const paths = { environmentsCreate: "/environments", environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive", tasks: (pid: string) => "/tasks?project_id=" + enc(pid), + tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string) => { + let u = "/tasks?project_id=" + enc(pid) + "&limit=" + limit + "&offset=" + offset + "&order=desc"; + if (status) u += "&status=" + enc(status); + if (executor) u += "&executor=" + enc(executor); + if (q && q.trim()) u += "&q=" + enc(q.trim()); + return u; + }, tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent), tasksCreate: "/tasks", task: (id: string) => "/tasks/" + enc(id), diff --git a/cloud/console-react/src/context.ts b/cloud/console-react/src/context.ts index 917bc20..58a41a2 100644 --- a/cloud/console-react/src/context.ts +++ b/cloud/console-react/src/context.ts @@ -22,6 +22,7 @@ export interface ConsoleCtx { tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void; statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void; filteredTasks: Task[]; + taskPage: number; taskTotalPages: number; taskTotal: number; taskStatusCounts: Record; goToTaskPage: (p: number) => void; currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string; showDetail: (id: string) => void; doAction: (kind: string) => void; taskCost: () => void; costBox: string; costSummary: TaskCost | null | undefined; forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise; forkMsg: string; diff --git a/cloud/console-react/src/hooks/useConsoleStore.ts b/cloud/console-react/src/hooks/useConsoleStore.ts index 1cdcee5..bb6de92 100644 --- a/cloud/console-react/src/hooks/useConsoleStore.ts +++ b/cloud/console-react/src/hooks/useConsoleStore.ts @@ -27,7 +27,7 @@ export function useConsoleStore(): ConsoleCtx { const selectProject = useCallback((id: string) => { catalog.setProjectId(id); projectIdRef.current = id; - void taskList.refreshTasks(id); + void taskList.refreshTasksFirstPage(id); void catalog.refreshEnvs(id); void catalog.refreshMembers(id); void catalog.refreshSkills(id); @@ -128,6 +128,8 @@ export function useConsoleStore(): ConsoleCtx { executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter, searchText: taskList.searchText, setSearchText: taskList.setSearchText, filteredTasks: taskList.filteredTasks, + taskPage: taskList.page, taskTotalPages: taskList.totalPages, taskTotal: taskList.taskTotal, + taskStatusCounts: taskList.taskStatusCounts, goToTaskPage: taskList.goToPage, currentTask: detail.currentTask, detailBody: detail.detailBody, actionMsg: detail.actionMsg, actionReasons: detail.actionReasons, showDetail: (id: string) => void detail.showDetail(id), doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary, @@ -147,7 +149,7 @@ export function useConsoleStore(): ConsoleCtx { threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction, dispatch: async (body, atts) => { const msg = await taskList.dispatch(body, atts); - void taskList.refreshTasks(projectIdRef.current); + void taskList.refreshTasksFirstPage(projectIdRef.current); return msg; }, dispatchMsg: "", diff --git a/cloud/console-react/src/hooks/useTaskList.ts b/cloud/console-react/src/hooks/useTaskList.ts index f6f0933..56aae73 100644 --- a/cloud/console-react/src/hooks/useTaskList.ts +++ b/cloud/console-react/src/hooks/useTaskList.ts @@ -1,36 +1,91 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api, shortId, uploadAttachments } from "../api/client"; import { paths } from "../api/paths"; import type { DispatchBody, Task } from "../api/types"; +export const TASK_PAGE_SIZE = 20; + +// Task list, server-side paginated. The old hook fetched every task +// in the project and filtered client-side; at scale that made every +// 10 s refresh a full-table fetch and a full render. Now each fetch +// asks for one page (newest first) with the current filters, and the +// server returns total + status_counts for that filter set. tasks +// here is ONLY the current page; taskTotal / status counts are the +// project-wide / filtered totals from the server, never page-only +// numbers (DataCards must not present a page as the whole project). +// Page state lives in a ref as well as state, because refreshTasks +// is called from auto-refresh and from other hooks that must not +// reset the page, while filter/project changes reset it to page 1. export function useTaskList() { const [tasks, setTasks] = useState([]); const [taskError, setTaskError] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [executorFilter, setExecutorFilter] = useState(""); const [searchText, setSearchText] = useState(""); + const [page, setPageState] = useState(0); + const [taskTotal, setTaskTotal] = useState(0); + const [taskStatusCounts, setTaskStatusCounts] = useState>({}); + const pageRef = useRef(0); + const filterRef = useRef({ status: "", executor: "", q: "" }); + filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText }; + const lastProjectRef = useRef(""); - const refreshTasks = useCallback(async (pid?: string, currentPid?: string) => { + const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => { const id = pid !== undefined ? pid : currentPid; - if (!id) { setTasks([]); return; } + if (!id) { setTasks([]); setTaskTotal(0); setTaskStatusCounts({}); return; } + lastProjectRef.current = id; + const p = pageOverride !== undefined ? pageOverride : pageRef.current; + const f = filterRef.current; try { - const d = await api<{ tasks: Task[] }>(paths.tasks(id)); + const d = await api<{ tasks: Task[]; total?: number; status_counts?: Record }>( + paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q)); setTasks(d.tasks || []); + setTaskTotal(d.total ?? (d.tasks || []).length); + setTaskStatusCounts(d.status_counts || {}); setTaskError(""); } catch (e: any) { setTaskError("加载失败:" + e.message); } }, []); - const filteredTasks = useMemo(() => { - const q = searchText.trim().toLowerCase(); - return tasks.filter((t) => { - if (statusFilter && t.status !== statusFilter) return false; - if (executorFilter && (t.executor || "") !== executorFilter) return false; - if (q && !((t.id || "") + " " + (t.prompt || "")).toLowerCase().includes(q)) return false; - return true; - }); - }, [tasks, statusFilter, executorFilter, searchText]); + const setPage = useCallback((p: number) => { + const next = Math.max(0, p); + pageRef.current = next; + setPageState(next); + }, []); - const taskCountText = filteredTasks.length + " 个任务(共 " + tasks.length + " 个)"; + const goToPage = useCallback((p: number) => { + setPage(p); + void refreshTasks(lastProjectRef.current || undefined, undefined, p); + }, [refreshTasks, setPage]); + + // Filter changes refetch page 1 (debounced, so typing a search + // query does not fire one request per keystroke). Project changes + // do not fire this effect (filters are unchanged), so the store's + // explicit refreshTasksFirstPage remains the only fetch there. + useEffect(() => { + if (!lastProjectRef.current) return; + const t = setTimeout(() => { void refreshTasks(lastProjectRef.current, undefined, 0); }, 300); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter, executorFilter, searchText]); + + // Filter/project changes reset to page 1: the old page may not + // exist under the new filter, and staying on it would show an + // empty page with a misleading "共 N 个" count. + const setStatusFilterPage = useCallback((v: string) => { setStatusFilter(v); setPage(0); }, [setPage]); + const setExecutorFilterPage = useCallback((v: string) => { setExecutorFilter(v); setPage(0); }, [setPage]); + const setSearchTextPage = useCallback((v: string) => { setSearchText(v); setPage(0); }, [setPage]); + const refreshTasksFirstPage = useCallback(async (pid?: string) => { + setPage(0); + await refreshTasks(pid, undefined, 0); + }, [refreshTasks, setPage]); + + const totalPages = Math.max(1, Math.ceil(taskTotal / TASK_PAGE_SIZE)); + const taskCountText = taskTotal === 0 + ? "共 0 个任务" + : "共 " + taskTotal + " 个任务,第 " + (page + 1) + " / " + totalPages + " 页"; + // Back-compat for the list section: it renders the current page + // as-is (server already ordered newest first). + const filteredTasks = tasks; const dispatch = useCallback(async (body: DispatchBody, atts: Array<{ path: string; file: File }>): Promise => { const payload: DispatchBody = { ...body }; @@ -41,5 +96,11 @@ export function useTaskList() { return "任务已建:" + shortId(t.id); }, []); - return { tasks, taskError, statusFilter, setStatusFilter, executorFilter, setExecutorFilter, searchText, setSearchText, filteredTasks, taskCountText, refreshTasks, dispatch }; + return { + tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage, + executorFilter, setExecutorFilter: setExecutorFilterPage, + searchText, setSearchText: setSearchTextPage, + filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage, + page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch, + }; } diff --git a/cloud/console-react/src/sections/DataCards.tsx b/cloud/console-react/src/sections/DataCards.tsx index 01a375e..085d037 100644 --- a/cloud/console-react/src/sections/DataCards.tsx +++ b/cloud/console-react/src/sections/DataCards.tsx @@ -30,16 +30,18 @@ export default function DataCards() { : ctx.quotaText.includes("未填 key") || ctx.quotaText === "未加载" ? "未加载" : quotaMatch ? "$" + quotaMatch[1] : "配额"; + // Status counts come from the server (all project rows), not from + // ctx.tasks, which is now only the current page — counting the page + // would present 20 tasks as the whole project. 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) + .map((s) => s + " " + (ctx.taskStatusCounts[s] || 0)) .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 : "") }; + : { value: String(ctx.taskTotal), sub: ctx.taskCountText + " | " + statusCounts + (selectedProject ? " | " + selectedProject.name : "") }; let workersCard: { value: string; sub: string }; if (ctx.workersError) { diff --git a/cloud/console-react/src/sections/TaskListSection.tsx b/cloud/console-react/src/sections/TaskListSection.tsx index d607594..fecb93d 100644 --- a/cloud/console-react/src/sections/TaskListSection.tsx +++ b/cloud/console-react/src/sections/TaskListSection.tsx @@ -45,12 +45,17 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) { {ctx.taskCountText} +
+ + 第 {ctx.taskPage + 1} / {ctx.taskTotalPages} 页(每页 20 条,最新在前) + +
{ctx.taskError ?
{ctx.taskError}
: !ctx.projectId ?
先选项目
- : ctx.filteredTasks.length === 0 ?
{ctx.tasks.length ? "没有符合筛选条件的任务" : "该项目暂无任务"}
+ : ctx.filteredTasks.length === 0 ?
{ctx.taskTotal ? "没有符合筛选条件的任务" : "该项目暂无任务"}
: (
    - {ctx.filteredTasks.slice().reverse().map((t) => { + {ctx.filteredTasks.map((t) => { const [dur, durTitle] = durationOf(t); const [tok, tokTitle] = tokenOf(t); const fc = firstChar(t); diff --git a/cloud/console-react/src/tokens.css b/cloud/console-react/src/tokens.css index b988ac6..3807830 100644 --- a/cloud/console-react/src/tokens.css +++ b/cloud/console-react/src/tokens.css @@ -56,6 +56,28 @@ button.danger:hover{background:var(--danger-bg);color:var(--danger)} .dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px} .dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)} .dash-nav a:hover{background:rgba(255,255,255,.12);text-decoration:none} +/* Tab nav: buttons styled like the old anchor nav (dark header, white + text), active tab gets the hover background permanently. */ +.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)} +.tab-nav button:hover{background:rgba(255,255,255,.12)} +.tab-nav button.active{background:rgba(255,255,255,.18)} +/* Tab pages: the main page is a flex column (not the old 3-column + grid), so a tab's content spans the full width. The workbench tab + is a 2-column grid (tasks left, dispatch + conversation + detail + right) at >=900px and a single column below that; other tabs are + a single stacked column. Workbench columns keep the equal-height + rule from the wide/medium blocks (fixed height, internal scroll). */ +.dash-page-tabs{display:flex} +.dash-page-tabs > .tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px} +.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start} +.tab-workbench > *{min-width:0} +@media (max-width:899px){ + .tab-workbench{grid-template-columns:1fr} + /* Narrow workbench keeps the old single-column order (dispatch + first, then tasks, then conversation) even though the DOM now + groups tasks in the left column wrapper. */ + .tab-workbench #sec-dispatch{order:1} .tab-workbench #sec-tasks{order:2} .tab-workbench #sec-conv{order:3} +} .dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px} .dash-page > *{min-width:0} /* grid/flex children must shrink, never push the page wider; long lines scroll inside pre/.trace-box/.table-wrap instead */ .dash-section > *{min-width:0} diff --git a/cloud/console.html b/cloud/console.html index 7504ba5..303f870 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -4,7 +4,7 @@ Codex 命令台 - - +`),N(X||"(暂无 result/error 输出)")}catch{}$t(H.id),Pe(H.id),we(H.id),Tl(H.id),da(H),sl(H.id),fl(H.id),Vt(H.id),Ul(H.id),Dl(H.id)}},[$t,Pe,we,Tl,da,sl,fl,Vt,Ul,Dl]),ma=h.useCallback(async H=>{const D=se.current;if(!D)return;const X={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(X[H]&&!window.confirm(X[H])))try{const K=await at(tt.taskAction(D.id,H),"POST",{});b(H+" 已提交:"+JSON.stringify(K).slice(0,300)),r(),await pe(D.id)}catch(K){b(H+" 失败:"+K.message)}},[pe,r]),su=h.useCallback(async()=>{const H=se.current;if(H)try{const D=await at(tt.taskCost(H.id));j(JSON.stringify(D,null,2).slice(0,4e3))}catch(D){j("查成本失败:"+D.message)}},[]),fu=h.useCallback(async H=>{const D=se.current;if(D){if(!H.trim()){St("分支消息必填");return}if(!uu.includes(D.status)){St("仅终态任务可分支");return}try{const X=await at(tt.taskFork(D.id),"POST",{message:H.trim()});St("分支任务已建:"+X.id.slice(0,8)),r()}catch(X){St("分支失败:"+X.message)}}},[r]),Fl=h.useCallback(async H=>{const D=se.current;if(D)try{await at(tt.taskReviews(D.id),"POST",H),Xt("评审已登记(requested)"),await we(D.id)}catch(X){Xt("登记评审需求失败:"+X.message)}},[we]),Ce=h.useCallback(H=>{const D=se.current;if(!D)return;const X=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");X!==null&&at(tt.taskReviewFindings(D.id,H),"POST",{findings:X}).then(()=>void we(D.id)).catch(K=>Xt("写 findings 失败:"+K.message))},[we]),rl=h.useCallback((H,D)=>{const X=se.current;if(!X)return;const K=D==="approved"?"approve":"deny";at(tt.taskApprovalDecision(X.id,H,K),"POST",{}).then(()=>{vt("已记录"+(D==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Tl(X.id)}).catch(M=>vt("决定失败:"+M.message))},[Tl]),Ae=h.useCallback(async(H,D,X)=>{const K=se.current;if(K){if(!D.trim()){ce("目标任务 id 不能为空");return}if(!X.trim()){ce("消息正文不能为空");return}try{const M=await at(tt.taskMessagesTo(D.trim()),"POST",{from_task_id:K.id,body:X.trim(),mode:H});ce("已发送("+M.delivery+")"),await fl(K.id)}catch(M){ce("发送失败:"+M.message)}}},[fl]),ru=h.useCallback((H,D)=>{const X=se.current;if(!X)return;if(!["completed","failed","interrupted"].includes(X.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const K={};if(H==="rename"){if(!D.trim()){he("新线程名不能为空");return}K.name=D.trim()}at(tt.taskThreadAction(X.id,H),"POST",K).then(M=>{he(H+" 已提交:"+JSON.stringify(M).slice(0,300)),Vt(X.id),Ul(X.id)}).catch(M=>he(H+" 失败:"+M.message))},[Vt,Ul]),ou=h.useCallback(async H=>{const D=se.current;if(!D)return;const X=H.trim();if(!X){Le("消息不能为空");return}try{uu.includes(D.status)&&D.status!=="merged"?(await at(tt.taskContinue(D.id),"POST",{message:X}),Le("已重新排队,同一会话继续"),r(),await pe(D.id)):["queued","running"].includes(D.status)?(await at(tt.taskFollowups(D.id),"POST",{body:X,mode:"direct"}),Le("已直接发送"),await $t(D.id),await Pe(D.id)):Le("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(K){Le("发送失败:"+K.message)}},[pe,r,$t,Pe]),du=h.useCallback(async()=>{const H=se.current;if(!H){Ne("先选一个任务");return}try{const D=await at(tt.taskTrace(H.id)),X=new Blob([JSON.stringify({task_id:H.id,status:D.status,note:D.note||"",count:(D.entries||[]).length,entries:D.entries||[]},null,2)],{type:"application/json"}),K=document.createElement("a");K.href=URL.createObjectURL(X),K.download="trace-"+H.id.slice(0,8)+".json",document.body.appendChild(K),K.click(),K.remove(),setTimeout(()=>URL.revokeObjectURL(K.href),1e3),Ne("已下载完整链路 "+(D.entries||[]).length+" 条")}catch(D){Ne("下载失败:"+D.message)}},[]);return h.useEffect(()=>()=>fe(),[fe]),{currentTask:m,detailBody:_,actionMsg:C,actionReasons:B,costBox:Y,costSummary:k,forkVisible:F,setForkVisible:nt,forkMsg:V,coordChildren:_t,agents:W,agentsNote:Wt,reviews:yt,reviewMsg:ue,reviewError:Kt,traceEntries:At,traceNote:q,traceDone:lt,traceKind:ot,setTraceKind:ge,traceMsg:Be,followups:y,approvals:P,approvalMsg:pt,peerMessages:Et,peerMsg:it,threadText:Ga,threadGateNote:Ve,threadMsg:Rt,threadList:Kl,chatMsg:oa,chatHistory:zl,showDetail:pe,refreshDetailLists:Xa,doAction:ma,taskCost:su,forkTask:fu,postReview:Fl,submitFindings:Ce,decideApproval:rl,postPeer:Ae,threadAction:ru,sendChatMessage:ou,downloadTrace:du,refreshReviews:()=>void we(),refreshApprovals:()=>void Tl()}}function eg(i){const[r,m]=h.useState([]),[o,_]=h.useState(""),[N,C]=h.useState(null),[b,B]=h.useState(""),U=h.useCallback(async I=>{const F=I!==void 0?I:i();if(!F){m([]);return}try{const nt=await at(tt.projectAutomations(F));m(nt.automations||[])}catch(nt){_("自动化加载失败:"+nt.message)}},[i]),Y=h.useCallback(async(I,F,nt,V)=>{const St=i();if(!St){_("先选项目");return}if(!I.trim()){_("自动化名称必填");return}if(!F.trim()){_("定时任务内容必填");return}const _t={name:I.trim(),prompt:F.trim()};nt.trim()&&(_t.interval_seconds=parseInt(nt.trim(),10)),V.trim()&&(_t.repo_url=V.trim());try{await at(tt.projectAutomations(St),"POST",_t),_("自动化已创建(登记定义,到期后由执行器认领)"),await U(St)}catch(Bt){_("创建失败:"+Bt.message)}},[i,U]),j=h.useCallback(I=>{const F=i();F&&at(tt.projectAutomationCancel(F,I),"POST",{}).then(()=>{_("自动化已取消"),U(F)}).catch(nt=>_("取消失败:"+nt.message))},[i,U]),k=h.useCallback(async I=>{const F=I!==void 0?I:i();if(!F){C(null);return}try{const nt=await at(tt.projectTriage(F));C(nt),B("")}catch(nt){B("分诊加载失败:"+nt.message)}},[i]);return{automations:r,autoMsg:o,triage:N,triageError:b,refreshAutomations:U,createAutomation:Y,cancelAutomation:j,refreshTriage:k}}function lg(i,r){const m=h.useRef(i);m.current=i,h.useEffect(()=>{const o=setInterval(()=>{const _=document.activeElement;m.current(_?_.tagName==="SELECT":!1)},r);return()=>clearInterval(o)},[r])}function ag(i,r){const m=h.useRef(i);m.current=i,h.useEffect(()=>{const o=setInterval(()=>m.current(),r);return()=>clearInterval(o)},[r])}function ng(i){const r=m=>String(m).padStart(2,"0");return r(i.getHours())+":"+r(i.getMinutes())+":"+r(i.getSeconds())}function ug(){var nt;const i=Iy(),r=Py(),m=h.useRef("");m.current=i.projectId;const o=h.useCallback(()=>m.current,[]),_=eg(o),N=h.useCallback(V=>{const St=i.projects.find(_t=>_t.id===V);return St&&St.my_role||null},[i.projects]),C=h.useCallback(()=>{r.refreshTasks(void 0,m.current)},[r]),b=tg(N,C),[B,U]=h.useState("--:--:--"),Y=h.useCallback(V=>{i.setProjectId(V),m.current=V,r.refreshTasksFirstPage(V),i.refreshEnvs(V),i.refreshMembers(V),i.refreshSkills(V),_.refreshAutomations(V),_.refreshTriage(V)},[i,r,_]),j=(nt=i.projects[0])==null?void 0:nt.id;h.useEffect(()=>{!i.projectId&&j&&Y(j)},[i.projectId,j,Y]);const k=h.useCallback(V=>{i.saveKeyInput(V),V.trim()&&(i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),m.current&&(r.refreshTasks(m.current),i.refreshEnvs(m.current),i.refreshMembers(m.current)),i.loadRelayModels(),i.loadQuota(),i.loadGithubRepos(),i.probeAdmin())},[i,r]),I=h.useCallback(V=>{if(U(ng(new Date)),!bl()){i.refreshHealth();return}r.refreshTasks(void 0,m.current),i.refreshHealth(),i.loadQuota(),i.refreshWorkers(),i.refreshMembers(),i.refreshSkills(),_.refreshAutomations(),_.refreshTriage(),b.refreshDetailLists(),V||(i.refreshProjects(),i.refreshEnvs())},[i,r,_,b]),F=h.useCallback(()=>{bl()&&(i.loadRelayModels(),i.loadGithubRepos())},[i]);return h.useEffect(()=>{i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),i.loadQuota(),i.loadGithubRepos(),bl()&&i.loadRelayModels(),i.probeAdmin()},[]),{keySaved:i.keySaved,keyState:i.keyState,saveKeyInput:k,clearKeyInput:i.clearKeyInput,quotaText:i.quotaText,healthText:i.healthText,refreshHealth:i.refreshHealth,adminGate:i.adminGate,adminBody:i.adminBody,adminResult:i.adminResult,adminUserId:i.adminUserId,setAdminUserId:i.setAdminUserId,adminQuota:i.adminQuota,adminSweep:i.adminSweep,adminBackup:i.adminBackup,projects:i.projects,projectId:i.projectId,projectMsg:i.projectMsg,selectProject:Y,refreshProjects:i.refreshProjects,createProject:i.createProject,githubRepos:i.githubRepos,workers:i.workers,workersSummary:i.workersSummary,workersError:i.workersError,refreshWorkers:i.refreshWorkers,members:i.members,memberMsg:i.memberMsg,memberError:i.memberError,refreshMembers:()=>void i.refreshMembers(),inviteMember:i.inviteMember,changeMemberRole:i.changeMemberRole,skills:i.skills,apps:i.apps,mcpServers:i.mcpServers,skillMsg:i.skillMsg,skillError:i.skillError,mcpMsg:i.mcpMsg,mcpOauthMsg:i.mcpOauthMsg,mcpOauthBox:i.mcpOauthBox,refreshSkills:()=>void i.refreshSkills(),reloadMcp:i.reloadMcp,mcpOauth:i.mcpOauth,envs:i.envs,envCount:i.envCount,envMsg:i.envMsg,envRepoLine:i.envRepoLine,envRepoBar:i.envRepoBar,envRepoMsg:i.envRepoMsg,envSelect:i.envSelect,setEnvSelect:i.setEnvSelect,refreshEnvs:()=>void i.refreshEnvs(),createEnv:i.createEnv,archiveEnv:i.archiveEnv,saveEnvRepo:i.saveEnvRepo,models:i.models,modelCustomVisible:i.modelCustomVisible,tasks:r.tasks,taskCountText:r.taskCountText,taskError:r.taskError,refreshTasks:()=>void r.refreshTasks(void 0,m.current),statusFilter:r.statusFilter,setStatusFilter:r.setStatusFilter,executorFilter:r.executorFilter,setExecutorFilter:r.setExecutorFilter,searchText:r.searchText,setSearchText:r.setSearchText,filteredTasks:r.filteredTasks,taskPage:r.page,taskTotalPages:r.totalPages,taskTotal:r.taskTotal,taskStatusCounts:r.taskStatusCounts,goToTaskPage:r.goToPage,currentTask:b.currentTask,detailBody:b.detailBody,actionMsg:b.actionMsg,actionReasons:b.actionReasons,showDetail:V=>void b.showDetail(V),doAction:b.doAction,taskCost:b.taskCost,costBox:b.costBox,costSummary:b.costSummary,forkVisible:b.forkVisible,setForkVisible:b.setForkVisible,forkTask:b.forkTask,forkMsg:b.forkMsg,coordChildren:b.coordChildren,coordText:"",agents:b.agents,agentsNote:b.agentsNote,reviews:b.reviews,reviewMsg:b.reviewMsg,reviewError:b.reviewError,refreshReviews:b.refreshReviews,postReview:b.postReview,submitFindings:b.submitFindings,traceEntries:b.traceEntries,traceNote:b.traceNote,traceDone:b.traceDone,traceKind:b.traceKind,setTraceKind:b.setTraceKind,downloadTrace:b.downloadTrace,traceMsg:b.traceMsg,sendChatMessage:b.sendChatMessage,chatMsg:b.chatMsg,chatHistory:b.chatHistory,followups:b.followups,approvals:b.approvals,approvalMsg:b.approvalMsg,refreshApprovals:b.refreshApprovals,decideApproval:b.decideApproval,peerMessages:b.peerMessages,peerMsg:b.peerMsg,postPeer:b.postPeer,automations:_.automations,autoMsg:_.autoMsg,refreshAutomations:()=>void _.refreshAutomations(),createAutomation:_.createAutomation,cancelAutomation:_.cancelAutomation,triage:_.triage,triageError:_.triageError,refreshTriage:()=>void _.refreshTriage(),threadText:b.threadText,threadGateNote:b.threadGateNote,threadMsg:b.threadMsg,threadList:b.threadList,threadAction:b.threadAction,dispatch:async(V,St)=>{const _t=await r.dispatch(V,St);return r.refreshTasksFirstPage(m.current),_t},dispatchMsg:"",refreshAll:I,refreshSlow:F,refreshStamp:B}}function nu({label:i,value:r,sub:m}){return c.jsxs("div",{className:"dash-card data-card",children:[c.jsx("div",{className:"dash-card-label",children:i}),c.jsx("div",{className:"dash-card-value",children:r}),c.jsx("div",{className:"muted",children:m})]})}function ig(){const i=Ht(),r=i.healthText==="未加载"?"未加载":i.healthText.includes("worker_ok=true")?"正常":i.healthText.includes("worker_ok=false")?"异常":i.healthText.includes("加载失败")?"加载失败":"未知",m=i.quotaText.match(/剩余 \$([0-9.]+)/),o=i.quotaText.includes("加载失败")?"加载失败":i.quotaText.includes("未填 key")||i.quotaText==="未加载"?"未加载":m?"$"+m[1]:"配额",_=["running","completed","failed","queued","merged","interrupted"].map(j=>j+" "+(i.taskStatusCounts[j]||0)).join(" | "),N=i.projects.find(j=>j.id===i.projectId),C=i.taskError?{value:"加载失败",sub:i.taskError}:i.projectId?{value:String(i.taskTotal),sub:i.taskCountText+" | "+_+(N?" | "+N.name:"")}:{value:"先选项目",sub:i.projectMsg||"项目加载后自动选第一个"};let b;if(i.workersError)b={value:"加载失败",sub:i.workersError};else if(i.workers===null)b={value:i.workersSummary||"未加载",sub:"完整 worker 清单仅全局管理员可见"};else{const j=i.workers.filter(k=>{if(k.alive!==void 0&&k.alive!==null)return!!k.alive;const I=Vh(k).age;return I!==null&&I<=120}).length;b={value:j+" 在线 / "+i.workers.length,sub:i.workers.length-j+" 离线"}}const B=i.projectMsg.includes("加载失败")?{value:"加载失败",sub:i.projectMsg}:{value:i.projects.length+" 个",sub:N?"当前项目:"+N.name:"未选项目(加载后自动选第一个)"},U=[i.envMsg,i.memberError,i.skillError].filter(j=>j&&j.includes("失败")),Y=i.projectId?U.length?{value:"部分加载失败",sub:U.join(" | ")}:{value:i.envs.length+" 个环境",sub:i.members.length+" 成员 | "+i.skills.length+" 技能 | "+i.apps.length+" 应用 | "+i.mcpServers.length+" MCP"}:{value:"先选项目",sub:"选项目后显示环境 / 成员 / 技能计数"};return c.jsxs("section",{className:"dash-cards","aria-label":"数据卡片",children:[c.jsx(nu,{label:"健康",value:r,sub:i.healthText}),c.jsx(nu,{label:"配额",value:o,sub:i.quotaText}),c.jsx(nu,{label:"任务",value:C.value,sub:C.sub}),c.jsx(nu,{label:"Workers",value:b.value,sub:b.sub}),c.jsx(nu,{label:"项目",value:B.value,sub:B.sub}),c.jsx(nu,{label:"项目数据",value:Y.value,sub:Y.sub})]})}function Ft({id:i,className:r,title:m,children:o}){return c.jsxs("section",{id:i,className:"card"+(r?" "+r:""),children:[m?c.jsx("h2",{children:m}):null,o]})}function Ut({children:i}){return c.jsx("div",{className:"bar",children:i})}function Ol({items:i,empty:r}){return i.length?c.jsx("ul",{className:"data-list",children:i.map((m,o)=>c.jsx("li",{children:m},o))}):c.jsx("div",{className:"muted",children:r||"(暂无)"})}function cu({children:i}){return c.jsx("ul",{className:"data-list",children:i})}function cg(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState(""),[N,C]=h.useState(!1),[b,B]=h.useState(!1),[U,Y]=h.useState(!1),[j,k]=h.useState("docker"),[I,F]=h.useState(""),[nt,V]=h.useState(""),[St,_t]=h.useState(""),[Bt,W]=h.useState(""),[Qt,Wt]=h.useState(""),[ut,yt]=h.useState(""),[ne,ue]=h.useState(""),Xt=h.useRef(null),Kt=h.useRef(null),Pt=h.useRef(null),[At,st]=h.useState("");async function q(){var Be,Ne,y;if(st(""),!i.projectId){st("先选项目");return}if(!r.trim()){st("prompt 必填");return}const A={project_id:i.projectId,prompt:r.trim()};i.envSelect&&(A.environment_id=i.envSelect),j&&(A.executor=j);const lt=nt.trim()||I;lt&&(A.model=lt),St&&(A.effort=St);const ht=ut.trim()||Qt;if(ht&&(A.child_model=ht),ne&&(A.child_effort=ne),Bt&&(A.approval=Bt),U){if(Bt==="full"){st("计划模式不能与完全访问同用");return}if(N||b){st("计划模式不能与 goal 模式同用");return}A.plan=1}b?(A.goal_mode=1,A.no_time_limit=1):N&&(A.goal_mode=1);const ot=o.trim();if(ot){try{A.coordinator_parts=JSON.parse(ot)}catch{st("协调员子任务 JSON 解析失败");return}if(!Array.isArray(A.coordinator_parts)){st("协调员子任务必须是数组");return}if(N||b||U){st("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON");return}}const ge=$y(((Be=Xt.current)==null?void 0:Be.files)||null,((Ne=Kt.current)==null?void 0:Ne.files)||null,((y=Pt.current)==null?void 0:y.files)||null);try{const R=await i.dispatch(A,ge);st(R),m(""),_(""),Xt.current&&(Xt.current.value=""),Kt.current&&(Kt.current.value=""),Pt.current&&(Pt.current.value="")}catch(R){st("提交失败:"+((R==null?void 0:R.message)||String(R)))}}return c.jsxs(Ft,{id:"nav-newtask",className:"composer",title:"新建任务",children:[c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"环境",value:i.envSelect,onChange:A=>i.setEnvSelect(A.target.value),children:[c.jsx("option",{value:"",children:"环境:不指定(走项目仓库)"}),i.envs.map(A=>c.jsxs("option",{value:A.id,disabled:A.status!=="ready",children:[A.name,"(",A.status,")"]},A.id))]}),c.jsxs("select",{"aria-label":"执行器",value:j,onChange:A=>k(A.target.value),children:[c.jsx("option",{value:"docker",children:"docker(默认,多用户下隔离最强)"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"cloud",children:"cloud"})]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:N,onChange:A=>C(A.target.checked)})," goal 模式"]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:b,onChange:A=>B(A.target.checked)})," goal 模式无敌版"]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:U,onChange:A=>Y(A.target.checked)})," 计划模式(只读)"]})]}),c.jsx("textarea",{className:"prompt stacked",placeholder:"任务 prompt(必填)",value:r,onChange:A=>m(A.target.value)}),c.jsx("textarea",{className:"coord stacked",placeholder:"协调员子任务 JSON(可选),留空为普通任务",value:o,onChange:A=>_(A.target.value)}),c.jsxs("div",{className:"muted",children:["协调员格式:[","{",'"name":"后端","prompt":"做后端"',"}",",","{",'"name":"前端","prompt":"做前端"',"}","];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。"]}),c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"模型",value:I,onChange:A=>F(A.target.value),children:[c.jsx("option",{value:"",children:"模型:默认(跟随配置)"}),i.models.map(A=>c.jsx("option",{value:A,children:A},A))]}),i.modelCustomVisible?c.jsx("input",{type:"text",placeholder:"手动输入模型(中转列表取不到时用)",value:nt,onChange:A=>V(A.target.value)}):null,c.jsxs("select",{"aria-label":"推理强度",value:St,onChange:A=>_t(A.target.value),children:[c.jsx("option",{value:"",children:"推理强度:默认(跟随配置)"}),wh.map(A=>c.jsx("option",{value:A,children:A},A))]}),c.jsxs("select",{"aria-label":"权限",value:Bt,onChange:A=>W(A.target.value),children:[c.jsx("option",{value:"",children:"权限:默认(跟随配置)"}),c.jsx("option",{value:"ask",children:"请求批准"}),c.jsx("option",{value:"auto",children:"帮我批准"}),c.jsx("option",{value:"full",children:"完全访问"})]})]}),c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"子任务模型",value:Qt,onChange:A=>Wt(A.target.value),children:[c.jsx("option",{value:"",children:"子任务模型:跟随本任务模型"}),i.models.map(A=>c.jsx("option",{value:A,children:A},A))]}),c.jsx("input",{type:"text",placeholder:"子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)",value:ut,onChange:A=>yt(A.target.value)}),c.jsxs("select",{"aria-label":"子任务强度",value:ne,onChange:A=>ue(A.target.value),children:[c.jsx("option",{value:"",children:"子任务强度:跟随本任务强度"}),wh.map(A=>c.jsx("option",{value:A,children:A},A))]})]}),c.jsx("div",{className:"muted",children:"子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。"}),N?c.jsx("div",{className:"muted",children:"goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。"}):null,b?c.jsx("div",{className:"muted",children:"goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。"}):null,c.jsxs(Ut,{children:[c.jsx("span",{className:"muted",children:"附件(放进任务工作区,图片与普通文件不区分):"}),c.jsx("input",{ref:Xt,type:"file",multiple:!0,title:"文件"}),c.jsx("input",{ref:Kt,type:"file",multiple:!0,title:"文件夹",webkitdirectory:""}),c.jsx("input",{ref:Pt,type:"file",accept:"image/*",multiple:!0,title:"图片"})]}),c.jsxs(Ut,{children:[c.jsx("button",{className:"seal",onClick:q,children:"提交任务"}),c.jsx("span",{className:"muted",children:At||i.dispatchMsg})]})]})}function Lh(i){const r=(i.prompt||"").trim();return r?[...r][0]:i.id?[...i.id][0]:"?"}function zc(i){const r=Math.max(0,Math.floor(i));if(r<60)return r+"秒";if(r<3600){const _=Math.floor(r/60),N=r%60;return N?_+"分"+N+"秒":_+"分"}if(r<86400){const _=Math.floor(r/3600),N=Math.floor(r%3600/60);return N?_+"小时"+N+"分":_+"小时"}const m=Math.floor(r/86400),o=Math.floor(r%86400/3600);return o?m+"天"+o+"小时":m+"天"}function iu(i){if(i==null)return null;const r=Number(i);return Number.isFinite(r)?r:null}function Zh(i){const r=Date.now()/1e3,m=iu(i.started_at),o=iu(i.finished_at),_=iu(i.created_at);return m!==null&&o!==null&&o>=m?[zc(o-m),"总耗时:开始→结束(末次认领的执行时长)"]:m!==null&&i.status==="running"?["进行中 "+zc(r-m),"总耗时:进行中,已用时长从开始算起,随刷新更新"]:_!==null&&o!==null&&o>=_?[zc(o-_),"总耗时:创建→完成(含排队等待;无开始时间时的兜底口径)"]:_!==null&&(i.status==="queued"||i.status==="running")?[(i.status==="queued"?"排队中 ":"进行中 ")+zc(r-_),"总耗时:从创建算起的已用时长(无开始时间时的兜底口径)"]:["—","总耗时:无可用时间戳"]}function sg(i){if(i==null||!Number.isFinite(Number(i)))return"—";const r=Number(i)/1e6;return"$"+(r>0&&r<.01?r.toFixed(4):r.toFixed(2))}function Kh(i){const r=iu(i.total_tokens);if(r!==null)return[r.toLocaleString()+" token","token:服务端 total_tokens"];const m=iu(i.actual_tokens),o=iu(i.estimated_tokens);return m!==null||o!==null?[((m||0)+(o||0)).toLocaleString()+" token","token:actual + estimated 分项加总"]:["token —","token:本任务对象未带 token 字段;单任务真数在详情的成本里可查,不逐个调成本接口、不编造"]}function fg({onNew:i}){const r=Ht();return c.jsxs("div",{children:[c.jsxs("div",{className:"search-box",children:[c.jsxs("svg",{viewBox:"0 0 24 24",width:"18",height:"18",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round","aria-hidden":"true",children:[c.jsx("circle",{cx:"11",cy:"11",r:"7"}),c.jsx("path",{d:"M21 21l-4.3-4.3"})]}),c.jsx("input",{type:"text",placeholder:"搜索","aria-label":"搜索任务",value:r.searchText,onChange:m=>r.setSearchText(m.target.value)})]}),c.jsx("div",{className:"bar",children:c.jsxs("select",{"aria-label":"项目",value:r.projectId,onChange:m=>r.selectProject(m.target.value),children:[r.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,r.projects.map(m=>c.jsxs("option",{value:m.id,children:[m.name," (",He(m.id),")"]},m.id))]})}),c.jsxs("div",{className:"list-section-head",children:[c.jsx("span",{children:"主要聊天"}),c.jsx("button",{type:"button","aria-label":"派发新任务",title:"派发新任务",onClick:i,children:"+"})]}),c.jsxs("div",{className:"bar",children:[c.jsxs("select",{"aria-label":"状态筛选",value:r.statusFilter,onChange:m=>r.setStatusFilter(m.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),["queued","running","completed","failed","interrupted","merged","merge-failed"].map(m=>c.jsx("option",{value:m,children:m},m))]}),c.jsxs("select",{"aria-label":"执行器筛选",value:r.executorFilter,onChange:m=>r.setExecutorFilter(m.target.value),children:[c.jsx("option",{value:"",children:"执行器:全部"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud",children:"cloud"})]})]}),c.jsxs("div",{className:"bar",children:[c.jsx("button",{onClick:r.refreshTasks,children:"刷新"}),c.jsx("span",{className:"muted",children:r.taskCountText})]}),c.jsxs("div",{className:"bar",children:[c.jsx("button",{disabled:r.taskPage<=0,onClick:()=>r.goToTaskPage(r.taskPage-1),children:"上一页"}),c.jsxs("span",{className:"muted",children:["第 ",r.taskPage+1," / ",r.taskTotalPages," 页(每页 20 条,最新在前)"]}),c.jsx("button",{disabled:r.taskPage+1>=r.taskTotalPages,onClick:()=>r.goToTaskPage(r.taskPage+1),children:"下一页"})]}),r.taskError?c.jsx("div",{className:"err",children:r.taskError}):r.projectId?r.filteredTasks.length===0?c.jsx("div",{className:"muted",children:r.taskTotal?"没有符合筛选条件的任务":"该项目暂无任务"}):c.jsx("ul",{className:"issue-list",children:r.filteredTasks.map(m=>{var B;const[o,_]=Zh(m),[N,C]=Kh(m),b=Lh(m);return c.jsxs("li",{className:"issue-item"+(((B=r.currentTask)==null?void 0:B.id)===m.id?" selected":""),onClick:()=>r.showDetail(m.id),children:[c.jsx("span",{className:"issue-state st-"+m.status,"aria-hidden":"true"}),c.jsx("span",{className:"issue-first",title:"任务首字:"+b,"aria-hidden":"true",children:b}),c.jsxs("span",{style:{flex:1,minWidth:0},children:[c.jsxs("div",{className:"issue-title",children:[m.plan?"[计划] ":"",m.forked_from?"[分支] ":"",m.coordinator_parts?"[协调] ":"",m.parent_task_id?"[协调子任务] ":"",String(m.prompt||He(m.id)).slice(0,60)]}),c.jsxs("div",{className:"issue-meta",children:[He(m.id)," · ",c.jsx("span",{className:"st-"+m.status,children:m.status})," · ",m.executor||"",m.updated_at?" · "+new Date(m.updated_at*1e3).toLocaleString():""," · ",c.jsxs("span",{title:_,children:["耗时 ",o]})," · ",c.jsx("span",{title:C,children:N})]})]})]},m.id)})}):c.jsx("div",{className:"muted",children:"先选项目"})]})}function rg(i){const r=h.useRef(null),[m,o]=h.useState(!0),_=h.useRef(!0),N=()=>{const C=r.current;if(!C)return;const b=C.scrollHeight-C.scrollTop-C.clientHeight<80;_.current=b,o(b)};return h.useEffect(()=>{_.current&&r.current&&(r.current.scrollTop=r.current.scrollHeight)},[i]),{ref:r,following:m,onScroll:N}}function og({msg:i}){if(i.role==="status")return c.jsx("div",{className:"chat-status",children:i.text});const r=i.label||(i.role==="user"?"用户":i.role==="assistant"?"助手":"工具");return c.jsxs("div",{className:"gh-comment "+i.role,children:[c.jsx("div",{className:"gh-comment-header",children:c.jsx("span",{className:"gh-comment-author",children:r})}),c.jsx("div",{className:"gh-comment-body",children:i.text})]})}function dg({messages:i,empty:r}){const{ref:m,following:o,onScroll:_}=rg(i.length);return c.jsxs("div",{className:"chat-list",ref:m,onScroll:_,children:[i.length===0?c.jsx("div",{className:"muted",children:r||"(暂无消息)"}):i.map(N=>c.jsx(og,{msg:N},N.id)),!o&&i.length>0?c.jsx("button",{className:"chat-follow",onClick:()=>{m.current&&(m.current.scrollTop=m.current.scrollHeight)},children:"回到最新 ↓"}):null]})}function mg({onSend:i,disabled:r,placeholder:m}){const[o,_]=h.useState(""),N=()=>{const C=o.trim();!C||r||(i(C),_(""))};return c.jsxs("div",{className:"gh-comment-box",children:[c.jsx("textarea",{"aria-label":"会话输入",rows:3,placeholder:m||"消息",value:o,disabled:r,onChange:C=>_(C.target.value),onKeyDown:C=>{C.key==="Enter"&&!C.shiftKey&&(C.preventDefault(),N())}}),c.jsx("div",{className:"gh-comment-footer",children:c.jsx("button",{type:"button",className:"primary","aria-label":"发送",disabled:r||!o.trim(),onClick:N,children:"发送"})})]})}function Yh(i,r){const m=i.id||"trace-"+r;if(i.kind==="message"){const o=(i.phase?"["+i.phase+"] ":"")+(i.text||"");return{id:m,role:"assistant",kind:i.kind,text:i.truncated?o+` +(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:o,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const o=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:m,role:"tool",kind:i.kind,text:mr(i),label:o}}return i.kind==="plan"?{id:m,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:m,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:m,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:m,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:m,role:"status",kind:i.kind,text:mr(i)}}function hg(){const i=Ht(),r=i.currentTask,m=h.useMemo(()=>{if(!r)return[];let B;if(i.chatHistory){B=i.chatHistory.messages.map(F=>({id:F.id,role:F.role,text:F.text,label:F.label,kind:F.kind}));const k=new Set(B.map(F=>F.id)),I=i.traceEntries.filter(F=>F.id&&!k.has(F.id)&&!String(F.id).startsWith("fu-")).map((F,nt)=>Yh(F,nt));B=[...B,...I]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],I=i.traceEntries.filter(nt=>!String(nt.id||"").startsWith("fu-")).map((nt,V)=>Yh(nt,V)),F=i.followups.map((nt,V)=>({id:"followup-"+V,role:"user",text:String(nt.body||""),label:"后续消息"}));B=[...k,...I,...F]}const U=i.traceKind,Y=U?B.filter(k=>k.role==="user"||!k.kind||k.kind===U):B,j=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...j]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const o=uu.includes(r.status),_=["queued","running"].includes(r.status),N=o||_,C=o?"终态任务:发送走继续接口(同一会话再跑一轮)":_?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",b=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:B=>i.setTraceKind(B.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(B=>c.jsx("option",{value:B,children:B==="exec"?"命令":B==="file"?"文件":B==="call"?"调用":B==="tool"?"工具":B==="message"?"消息":B==="reasoning"?"思考":B==="plan"?"计划":"子代理"},B))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,b!==null&&b>0?c.jsxs("div",{className:"muted",children:["此前有 ",b," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(dg,{messages:m,empty:"(暂无会话消息)"}),c.jsx(mg,{disabled:!N,placeholder:"消息",onSend:B=>void i.sendChatMessage(B)}),c.jsxs("div",{className:"muted",children:[C,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function vg(){return Ht().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(hg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function yg(){const i=Ht(),r=i.currentTask,[m,o]=h.useState("");if(!r)return null;const _=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ut,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:_,title:_?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 merged)"}),_?c.jsx("button",{onClick:()=>i.setForkVisible(!0),children:"分支"}):null,c.jsx("button",{onClick:i.taskCost,children:"查成本"}),c.jsxs("span",{className:"muted",children:[i.actionMsg," ",i.actionReasons]})]}),i.forkVisible?c.jsxs("div",{children:[c.jsx("input",{type:"text",placeholder:"分支首轮消息(必填,带历史的新会话)",style:{width:"100%",boxSizing:"border-box",margin:"6px 0"},value:m,onChange:C=>o(C.target.value)}),c.jsxs(Ut,{children:[c.jsx("button",{className:"seal",onClick:async()=>{await i.forkTask(m),o("")},children:"创建分支任务"}),c.jsx("span",{className:"muted",children:i.forkMsg})]})]}):null]})}function gg(){const i=Ht(),[r,m]=h.useState("uncommittedChanges"),[o,_]=h.useState(""),[N,C]=h.useState(""),[b,B]=h.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:U=>m(U.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:o,onChange:U=>_(U.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:U=>C(U.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:U=>B(U.target.value)}),c.jsxs(Ut,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:o.trim()||void 0,sha:N.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(U=>{let Y=U.target_type||"";return U.branch&&(Y+=":"+U.branch),U.sha&&(Y+=":"+String(U.sha).slice(0,12)),U.instructions&&(Y+=":"+String(U.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",He(U.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(U.status||""),children:U.status}),U.created_at?" | "+Ya(U.created_at):"",c.jsx("br",{}),U.findings?String(U.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(U.id),children:"写 findings"})]},U.id)})})]})}function Sg(){const i=Ht();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",He(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function pg(){const i=Ht();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},m={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((o,_)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[o.kind||""]||o.kind||"",":"]}),o.name||""," ",c.jsx("span",{className:"st-"+(o.status||""),children:m[o.status||""]||o.status||"?"})," | 过程调用 "+(o.calls?o.calls.length:0)+" 条",o.updated_ts?" | 最近活动 "+Ya(o.updated_ts/1e3):"",o.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",o.prompt]})]}):null,o.calls&&o.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),o.calls.slice(-50).map((N,C)=>c.jsx("div",{children:mr(N)},C))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},_))})}function bg(){const i=Ht();return c.jsxs(c.Fragment,{children:[c.jsxs(Ut,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(Ol,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ya(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function xg(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:N=>m(N.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:o,onChange:N=>_(N.target.value)}),c.jsxs(Ut,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,o),_("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,o),_("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(Ol,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((N,C)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(N.task_id).slice(0,8),"|",N.mode,"|",N.status,"] ",String(N.body||"").slice(0,120)]},C))})]})}function jg(){const i=Ht(),[r,m]=h.useState(""),o=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:_=>m(_.target.value)}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(Ol,{empty:"暂无其他线程",items:i.threadList.map((_,N)=>c.jsxs("span",{className:"row-main",children:[_.name||_.thread_name||_.id||""," [",_.id||"","]",_.archived?" [已归档]":"",_.cwd?" "+_.cwd:""]},N))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Tg(){const i=Ht(),r=i.currentTask;if(!r)return c.jsx(Ft,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[m,o]=Zh(r),[_,N]=Kh(r),C=Lh(r),b=i.costSummary,B=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsxs(Ft,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+C,"aria-hidden":"true",children:C}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Zy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ya(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:o,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:m})]}),c.jsxs("div",{className:"detail-card",title:N,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:_})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:b===void 0?"加载中…":b===null?"—":sg(b.usd_micros)}),b===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):b===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(b.total_tokens??0).toLocaleString(),"(实际 ",(b.actual_tokens??0).toLocaleString()," / 估算 ",(b.estimated_tokens??0).toLocaleString(),",缓存 ",(b.cached_input_tokens??0).toLocaleString(),",无价 ",(b.unpriced_actual_tokens??0).toLocaleString(),")| 轮数 ",b.turns??0," | fallback ",b.fallback_count??0]})]})]})]}),c.jsx(Ft,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:He(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:He(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsx("dd",{children:r.executor||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:He(r.project_id)})]}),B?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:B})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:He(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(U=>U.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",He(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Ft,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Ft,{title:"操作与结果",children:[c.jsx(yg,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]}),c.jsx(Ft,{title:"协调子任务",children:c.jsx(Sg,{})}),c.jsx(Ft,{title:"子代理链路",children:c.jsx(pg,{})}),c.jsx(Ft,{title:"评审队列",children:c.jsx(gg,{})}),c.jsx(Ft,{title:"运行时审批队列",children:c.jsx(bg,{})}),c.jsx(Ft,{title:"给其他任务发消息",children:c.jsx(xg,{})}),c.jsx(Ft,{title:"线程",children:c.jsx(jg,{})})]})}function _g(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState(""),[N,C]=h.useState([]),[b,B]=h.useState(null),[U,Y]=h.useState(!1),[j,k]=h.useState(""),[I,F]=h.useState(""),nt=h.useCallback(async()=>{if(!i.keySaved){C([]);return}try{const W=await at(tt.userKeys);C(W.keys||[]),F("")}catch(W){F(W instanceof Error?W.message:"加载 key 列表失败")}},[i.keySaved]);h.useEffect(()=>{nt()},[nt]);const V=async()=>{k(""),F(""),Y(!1);try{const W=await at(tt.userKeys,"POST",{label:o.trim()||"api-key"});B(W),_(""),k("新 key 已生成,只显示一次:复制后请妥善保存。"),await nt()}catch(W){F(W instanceof Error?W.message:"生成 key 失败")}},St=async()=>{if(b)try{await navigator.clipboard.writeText(b.api_key),Y(!0)}catch{F("复制失败:请手动选中上方 key 文本复制")}},_t=()=>{b&&(i.saveKeyInput(b.api_key),B(null),k("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},Bt=async W=>{const Qt=(()=>{try{return bl().slice(-4)}catch{return""}})(),Wt=W.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":W.hint&&W.hint===Qt?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${W.label}」(后 4 位 ${W.hint||"无"})吗?${Wt}撤销不可恢复。`)){k(""),F("");try{await at(tt.userKey(W.id),"DELETE"),k(`已撤销 key「${W.label}」。`),await nt()}catch(ut){F(ut instanceof Error?ut.message:"撤销失败")}}};return c.jsxs(Ft,{id:"nav-key",title:"API Key",children:[c.jsxs(Ut,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:W=>m(W.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),m("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(Ut,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:o,onChange:W=>_(W.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void V(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void nt(),children:"刷新列表"})]}),b&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",b.label,",后 4 位 ",b.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",readOnly:!0,value:b.api_key,onFocus:W=>W.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void St(),children:U?"已复制":"复制"}),c.jsx("button",{onClick:_t,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>B(null),children:"关闭(不再显示)"})]})]}),j&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:j})}),I&&c.jsx("div",{children:c.jsx("span",{children:I})}),c.jsxs("div",{className:"api-key-list",children:[N.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),N.map(W=>c.jsxs("div",{children:[c.jsx("span",{children:W.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",W.hint||"无"," · 创建 ",Ya(W.created_at)," · 最近使用 ",W.last_used_at?Ya(W.last_used_at):"未使用过"]})," ",W.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ya(W.revoked_at),")"]}):c.jsx("button",{onClick:()=>void Bt(W),children:"撤销"})]},W.id))]})]})]})}function Eg(){const i=Ht();return c.jsxs(Ft,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(Ut,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Ng(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState(""),[N,C]=h.useState("");return c.jsxs(Ft,{id:"nav-projects",title:"项目",children:[c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:b=>i.selectProject(b.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(b=>c.jsxs("option",{value:b.id,children:[b.name," (",He(b.id),")"]},b.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:b=>m(b.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:o,onChange:b=>{_(b.target.value),b.target.value&&C(b.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(b=>c.jsx("option",{value:b.clone_url,children:b.repo_full_name},b.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:N,onChange:b=>C(b.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,N),m(""),C(""),_("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function Cg(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState("contributor"),N=i.projects.find(C=>C.id===i.projectId);return c.jsxs(Ft,{title:"项目成员",children:[N?c.jsxs("div",{className:"muted",children:[N.name," | ",N.id," | 仓库 ",N.repo_url||"未绑定"," | 我的角色 ",N.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:C=>m(C.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:o,onChange:C=>_(C.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,o),m("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(C=>{const b=(C.username||"")+(C.username?"("+String(C.user_id).slice(0,8)+")":String(C.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:b}),c.jsx("select",{"aria-label":"成员角色",value:C.role||"viewer",onChange:B=>void i.changeMemberRole(C.user_id,B.target.value),children:Ky.map(B=>c.jsx("option",{value:B,children:B},B))})]},C.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Ag(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState(""),[N,C]=h.useState("");return c.jsxs(Ft,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(Ut,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(b=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[b.name,"(",He(b.id),") / 分支 ",b.branch||"默认分支"," / 执行器 ",b.executor||"不限"," / 状态 ",b.status,b.status==="failed"&&b.error_text?" / 失败原因:"+b.error_text:""]}),b.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(b.id),children:"归档"})]},b.id))}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:b=>m(b.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:o,onChange:b=>_(b.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:N,onChange:b=>C(b.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,o,N),m(""),_("")},children:"新建环境"})]}),c.jsx("span",{className:"muted",children:i.envMsg})]})}function Mg(){const i=Ht(),[r,m]=h.useState("");return c.jsxs(Ft,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(Ut,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:o=>m(o.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(o=>c.jsx("option",{value:o.clone_url,children:o.repo_full_name},o.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Og(){const i=Ht();return c.jsxs(Ft,{id:"nav-workers",title:"Workers",children:[c.jsx(Ut,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((r,m)=>{const o=r.running===void 0||r.running===null?"?":r.running,_=r.capacity===void 0||r.capacity===null?"?":r.capacity,N=Vh(r),C=r.alive!==void 0&&r.alive!==null?!!r.alive:N.age!==null&&N.age<=120,b=C?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:C?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[r.worker_id||r.id||"?"," / ",r.executor||""," / 占用 ",o,"/",_,b," / 最后心跳 ",N.text]})},m)})})]})}function zg(){const i=Ht();return c.jsxs(Ft,{title:"健康与配额",children:[c.jsxs(Ut,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function Rg(){const i=Ht();return c.jsxs(Ft,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(Ut,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(Ol,{empty:"暂无技能",items:i.skills.map((r,m)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},m))}),c.jsx("h3",{children:"应用"}),c.jsx(Ol,{empty:"暂无应用",items:i.apps.map((r,m)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},m))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(Ol,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,m)=>{const o=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",o?" | "+o:""]},m)})})]})}function Dg(){const i=Ht(),[r,m]=h.useState("");return c.jsxs(Ft,{title:"MCP 操作",children:[c.jsxs(Ut,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(Ut,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:o=>m(o.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}function Ug(){const i=Ht(),[r,m]=h.useState(""),[o,_]=h.useState(""),[N,C]=h.useState(""),[b,B]=h.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:U=>m(U.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:o,onChange:U=>_(U.target.value)}),c.jsxs(Ut,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:N,onChange:U=>C(U.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:b,onChange:U=>B(U.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,o,N,b),m(""),_(""),C(""),B("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(Ol,{empty:"(暂无自动化)",items:i.automations.map(U=>c.jsxs("span",{className:"row-main",children:["[",U.status,U.due?"|到期":"","] ",U.name,"|每 ",U.interval_seconds," 秒|下次 ",Ya(U.next_run_at),U.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.cancelAutomation(U.id),children:"取消"})]}):null]},U.id))})]})}function Hg(){var m,o,_,N;const i=Ht(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((m=r.counts)==null?void 0:m.automations_due)||0,")"]}),c.jsx(Ol,{empty:"(无)",items:(r.automations_due||[]).map((C,b)=>c.jsxs("span",{className:"row-main",children:[C.name,"|每 ",C.interval_seconds," 秒"]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((o=r.counts)==null?void 0:o.failed_tasks)||0,")"]}),c.jsx(Ol,{empty:"(无)",items:(r.failed_tasks||[]).map((C,b)=>c.jsxs("span",{className:"row-main",children:[He(C.id),"|",C.status,"|",String(C.prompt||"").slice(0,80)]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((_=r.counts)==null?void 0:_.pending_followups)||0,")"]}),c.jsx(Ol,{empty:"(无)",items:(r.pending_followups||[]).map((C,b)=>c.jsxs("span",{className:"row-main",children:["任务 ",He(C.task_id),"|",String(C.body||"").slice(0,80)]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((N=r.counts)==null?void 0:N.recent_runs)||0,")"]}),c.jsx(Ol,{empty:"(无)",items:(r.recent_runs||[]).map((C,b)=>c.jsxs("span",{className:"row-main",children:[He(C.automation_id),"|",C.status,C.task_id?"|任务 "+He(C.task_id):"",C.task_status?"|"+C.task_status:""]},b))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function Bg(){return c.jsx(Ft,{id:"nav-automations",title:"自动化",children:c.jsx(Ug,{})})}function qg(){const i=Ht();return c.jsxs(Ft,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(Ut,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Hg,{})]})}const Jh=[["workbench","工作台"],["projects","项目"],["env","环境与 Workers"],["skills","技能与自动化"],["admin","管理与 Key"]];function Gh(){const i=window.location.hash.replace("#tab-","");return Jh.some(([r])=>r===i)?i:"workbench"}function kg({tab:i,setTab:r}){return c.jsxs("header",{className:"dash-topbar",children:[c.jsxs("div",{className:"dash-topbar-row",children:[c.jsx("span",{className:"dash-brand",children:"Codex 命令台"}),c.jsx(wg,{}),c.jsx("span",{className:"muted dash-hide-sm",children:c.jsx(Yg,{})}),c.jsx("span",{className:"muted dash-hide-sm",children:c.jsx(Gg,{})}),c.jsx("span",{className:"muted dash-stamp",children:c.jsx(Qg,{})})]}),c.jsx("nav",{className:"dash-nav tab-nav","aria-label":"页面签",children:Jh.map(([m,o])=>c.jsx("button",{type:"button",className:i===m?"active":"",onClick:()=>r(m),children:o},m))})]})}function wg(){const i=Ht();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function Yg(){return c.jsx(c.Fragment,{children:Ht().healthText})}function Gg(){return c.jsx(c.Fragment,{children:Ht().quotaText})}function Qg(){return c.jsxs(c.Fragment,{children:["自动刷新 · 每 10 秒 · 已刷新 ",Ht().refreshStamp]})}function Xg(){const i=ug();lg(N=>i.refreshAll(N),1e4),ag(()=>i.refreshSlow(),6e4);const[r,m]=h.useState(()=>Gh()),o=N=>{m(N);try{window.history.replaceState(null,"","#tab-"+N)}catch{}};h.useEffect(()=>{const N=()=>m(Gh());return window.addEventListener("hashchange",N),()=>window.removeEventListener("hashchange",N)},[]);const _=()=>{o("workbench"),setTimeout(()=>{var N;return(N=document.getElementById("sec-dispatch"))==null?void 0:N.scrollIntoView({behavior:"smooth"})},0)};return c.jsxs(Xh.Provider,{value:i,children:[c.jsx(kg,{tab:r,setTab:o}),c.jsxs("main",{className:"dash-page dash-page-tabs",children:[c.jsx(ig,{}),r==="workbench"?c.jsxs("div",{className:"tab-content tab-workbench",children:[c.jsx("div",{className:"dash-col dash-col-left",children:c.jsxs("section",{id:"sec-tasks",className:"dash-section card",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(fg,{onNew:_})]})}),c.jsxs("div",{className:"dash-col dash-col-mid",children:[c.jsx("section",{id:"sec-dispatch",className:"dash-section",children:c.jsx(cg,{})}),c.jsxs("section",{id:"sec-conv",className:"dash-section",children:[c.jsxs("div",{className:"card",children:[c.jsx("h2",{children:"对话"}),c.jsx(vg,{})]}),c.jsx(Tg,{})]})]})]}):r==="projects"?c.jsx("div",{className:"tab-content",children:c.jsxs("section",{id:"sec-projects",className:"dash-section",children:[c.jsx(Ng,{}),c.jsx(Cg,{})]})}):r==="env"?c.jsxs("div",{className:"tab-content",children:[c.jsxs("section",{id:"sec-env",className:"dash-section",children:[c.jsx(Ag,{}),c.jsx(Mg,{})]}),c.jsxs("section",{id:"sec-workers",className:"dash-section",children:[c.jsx(Og,{}),c.jsx(zg,{})]})]}):r==="skills"?c.jsxs("div",{className:"tab-content",children:[c.jsxs("section",{id:"sec-skills",className:"dash-section",children:[c.jsx(Rg,{}),c.jsx(Dg,{})]}),c.jsx("section",{id:"sec-automations",className:"dash-section",children:c.jsx(Bg,{})}),c.jsx("section",{id:"sec-triage",className:"dash-section",children:c.jsx(qg,{})})]}):c.jsxs("div",{className:"tab-content",children:[c.jsx("section",{id:"sec-admin",className:"dash-section",children:c.jsx(Eg,{})}),c.jsx("section",{id:"sec-key",className:"dash-section",children:c.jsx(_g,{})})]})]})]})}Vy.createRoot(document.getElementById("root")).render(c.jsx(By.StrictMode,{children:c.jsx(Xg,{})})); +
    diff --git a/cloud/server.py b/cloud/server.py index 1bd0eef..2390c4d 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -4495,6 +4495,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._send_json(201, task) def _list_tasks(self, user_id, query): + # GET /tasks: server-side pagination + filtering. + # - No limit param: old behaviour, all rows in created_at + # order (coord children and existing callers rely on it). + # - limit param: limit 1..100, offset >= 0 (invalid -> 400), + # order=desc (newest first, the list's display order), + # status/executor exact match, q case-insensitive + # substring over id + prompt (same rule the frontend + # used to apply client-side). total is the filtered + # count before slicing, status_counts is over all + # project rows (so the data card's breakdown is not + # skewed by the current filter or page). project_ids = query.get("project_id") project_id = project_ids[0] if project_ids else None if not project_id: @@ -4508,6 +4519,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): parent_ids = query.get("parent_task_id") if parent_ids: parent_task_id = parent_ids[0] + limit = None + offset = 0 + if query.get("limit"): + try: + limit = int(query["limit"][0]) + offset = int(query["offset"][0]) if query.get("offset") else 0 + except (TypeError, ValueError): + self._send_json(400, {"error": "limit/offset must be integers"}) + return + if limit < 1 or limit > 100 or offset < 0: + self._send_json(400, {"error": "limit must be 1..100, offset >= 0"}) + return + order_desc = bool(query.get("order") and query["order"][0] == "desc") + status_f = query["status"][0] if query.get("status") else None + executor_f = query["executor"][0] if query.get("executor") else None + q_f = query["q"][0].lower() if query.get("q") else None conn = self._db() try: if parent_task_id: @@ -4523,13 +4550,38 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): " ORDER BY created_at, id", (project_id,), ).fetchall() - amap = _attachments_map(conn, [r["id"] for r in rows]) - tmap = _task_tokens_map(conn, [r["id"] for r in rows]) + status_counts = {} + for r in rows: + s = r["status"] + status_counts[s] = status_counts.get(s, 0) + 1 + if parent_task_id: + filtered = rows + else: + filtered = [] + for r in rows: + d = _task_to_dict(r, [], 0) + if status_f and d["status"] != status_f: + continue + if executor_f and (d["executor"] or "") != executor_f: + continue + if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower(): + continue + filtered.append(r) + total = len(filtered) + if order_desc: + filtered = list(reversed(filtered)) + page_rows = filtered[offset:offset + limit] if limit is not None else filtered + amap = _attachments_map(conn, [r["id"] for r in page_rows]) + tmap = _task_tokens_map(conn, [r["id"] for r in page_rows]) finally: conn.close() - self._send_json(200, {"tasks": [ + out = {"tasks": [ _task_to_dict(r, amap[r["id"]], tmap.get(r["id"], 0)) - for r in rows]}) + for r in page_rows], "total": total, "status_counts": status_counts} + if limit is not None: + out["limit"] = limit + out["offset"] = offset + self._send_json(200, out) def _fetch_task(self, task_id): conn = self._db() diff --git a/cloud/tests/test_task_list_pagination.py b/cloud/tests/test_task_list_pagination.py new file mode 100644 index 0000000..8a4a0b5 --- /dev/null +++ b/cloud/tests/test_task_list_pagination.py @@ -0,0 +1,135 @@ +"""任务列表服务端分页:limit/offset/总数/服务端过滤/状态统计。 + +用真实 HTTP 服务 + 临时 SQLite:建 5 个任务(2 completed、 +1 failed、2 queued 交错 prompt),分别断言: +- 无 limit 时旧行为不变:全量、created_at 正序、total=5; +- limit=2&order=desc 时第 1 页是最新的 2 条,total=5; +- offset=2 时第 2 页是接下来 2 条,与第 1 页不重叠; +- status 过滤只回该状态,total 是过滤后计数; +- q 过滤按 id/prompt 子串(不区分大小写); +- limit=0 / limit=101 / offset=-1 回 400; +- status_counts 是全项目统计,不受当前过滤与分页影响。 +""" + +import http.client +import json +import os +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.server import create_server + + +class ListPaginationTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + self.ids = [] + for prompt in ["alpha one", "beta two", "alpha three", "gamma four", "alpha five"]: + status, t = self._req("POST", "/tasks", + {"project_id": self.project["id"], "prompt": prompt}, + self.owner_key) + self.assertEqual(status, 201) + self.ids.append(t["id"]) + # 直接改库状态:前 2 条 completed,第 3 条 failed,其余 queued。 + import sqlite3 + conn = sqlite3.connect(self.db_path) + conn.execute("UPDATE tasks SET status='completed' WHERE id IN (?, ?)", + (self.ids[0], self.ids[1])) + conn.execute("UPDATE tasks SET status='failed' WHERE id=?", (self.ids[2],)) + conn.commit() + conn.close() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + if key is not None: + hdrs["X-API-Key"] = key + try: + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + data = resp.read() + finally: + conn.close() + try: + parsed = json.loads(data.decode("utf-8")) if data else None + except ValueError: + parsed = None + return resp.status, parsed + + def _list(self, qs=""): + return self._req("GET", "/tasks?project_id=" + self.project["id"] + qs, + None, self.owner_key) + + def test_no_limit_is_old_behaviour(self): + status, d = self._list() + self.assertEqual(status, 200) + self.assertEqual([t["id"] for t in d["tasks"]], self.ids) + self.assertEqual(d["total"], 5) + + def test_desc_pages_do_not_overlap(self): + status, p1 = self._list("&limit=2&order=desc") + self.assertEqual(status, 200) + self.assertEqual(p1["total"], 5) + self.assertEqual([t["id"] for t in p1["tasks"]], + [self.ids[4], self.ids[3]]) + status, p2 = self._list("&limit=2&offset=2&order=desc") + self.assertEqual(status, 200) + self.assertEqual([t["id"] for t in p2["tasks"]], + [self.ids[2], self.ids[1]]) + self.assertEqual(set(t["id"] for t in p1["tasks"]).isdisjoint( + set(t["id"] for t in p2["tasks"])), True) + + def test_status_and_q_filters(self): + status, d = self._list("&status=completed") + self.assertEqual(status, 200) + self.assertEqual(d["total"], 2) + self.assertEqual(set(t["status"] for t in d["tasks"]), {"completed"}) + status, d = self._list("&q=ALPHA") + self.assertEqual(status, 200) + self.assertEqual(d["total"], 3) + + def test_status_counts_ignore_filter_and_page(self): + status, d = self._list("&status=completed&limit=1&order=desc") + self.assertEqual(status, 200) + self.assertEqual(len(d["tasks"]), 1) + self.assertEqual(d["status_counts"], { + "completed": 2, "failed": 1, "queued": 2}) + + def test_invalid_pagination_is_400(self): + for qs in ["&limit=0", "&limit=101", "&limit=2&offset=-1", "&limit=abc"]: + status, _ = self._list(qs) + self.assertEqual(status, 400, qs)