import { useCallback, useEffect, useRef, useState } from "react"; import { api, getKey } from "../api/client"; import { paths } from "../api/paths"; import { TRACE_TERMINAL } from "../api/types"; import type { Agent, Approval, ChatHistory, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types"; // Task detail state: the selected task, its trace SSE/one-shot stream, // and every structured sub-list. Chat sending lives here too: // terminal -> /continue, queued/running -> /followups (direct). export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksReload: () => void) { const [currentTask, setCurrentTask] = useState(null); const [detailBody, setDetailBody] = useState(""); const [actionMsg, setActionMsg] = useState(""); const [actionReasons, setActionReasons] = useState(""); const [costBox, setCostBox] = useState(""); const [forkVisible, setForkVisible] = useState(false); const [forkMsg, setForkMsg] = useState(""); const [coordChildren, setCoordChildren] = useState([]); const [agents, setAgents] = useState([]); const [agentsNote, setAgentsNote] = useState(""); const [reviews, setReviews] = useState([]); const [reviewMsg, setReviewMsg] = useState(""); const [reviewError, setReviewError] = useState(""); const [traceEntries, setTraceEntries] = useState([]); const [traceNote, setTraceNote] = useState(""); const [traceDone, setTraceDone] = useState(""); const [traceKind, setTraceKind] = useState(""); const [traceMsg, setTraceMsg] = useState(""); const [followups, setFollowups] = useState([]); const [approvals, setApprovals] = useState([]); const [approvalMsg, setApprovalMsg] = useState(""); const [peerMessages, setPeerMessages] = useState([]); const [peerMsg, setPeerMsg] = useState(""); const [threadText, setThreadText] = useState(""); const [threadGateNote, setThreadGateNote] = useState(""); const [threadMsg, setThreadMsg] = useState(""); const [threadList, setThreadList] = useState([]); const [chatMsg, setChatMsg] = useState(""); const [chatHistory, setChatHistory] = useState(null); const traceAbortRef = useRef(null); const agentTimerRef = useRef | null>(null); const currentTaskRef = useRef(null); currentTaskRef.current = currentTask; const stopTrace = useCallback(() => { if (traceAbortRef.current) { try { traceAbortRef.current.abort(); } catch { /* noop */ } traceAbortRef.current = null; } if (agentTimerRef.current) { clearTimeout(agentTimerRef.current); agentTimerRef.current = null; } }, []); const loadAgents = useCallback(async (taskId: string) => { try { const d = await api<{ agents?: Agent[]; note?: string }>(paths.taskAgents(taskId)); setAgents(d.agents || []); setAgentsNote(d.note || ""); } catch (e: any) { setAgents([]); setAgentsNote("子代理链路加载失败:" + e.message); } }, []); const scheduleAgents = useCallback((taskId: string) => { if (agentTimerRef.current) clearTimeout(agentTimerRef.current); agentTimerRef.current = setTimeout(() => void loadAgents(taskId), 1000); }, [loadAgents]); const loadCoordChildren = useCallback(async (task: Task) => { if (!task.coordinator_parts) { setCoordChildren([]); return; } try { const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id)); setCoordChildren(d.tasks || []); } catch { setCoordChildren([]); } }, []); const loadFollowups = useCallback(async (taskId: string) => { try { const d = await api<{ followups: Followup[] }>(paths.taskFollowups(taskId)); setFollowups(d.followups || []); } catch { setFollowups([]); } }, []); // True alternating history: server-merged prompt + persisted // continues + followups + trace, in timestamp order. On failure // (old server without /chat-history) keep null and ContinuePanel // falls back to its previous prompt + trace + followups view. const loadChatHistory = useCallback(async (taskId: string) => { try { const d = await api(paths.taskChatHistory(taskId)); setChatHistory(d); } catch { setChatHistory(null); } }, []); const loadReviews = useCallback(async (taskId?: string) => { const id = taskId || currentTaskRef.current?.id; if (!id) return; try { const d = await api<{ reviews: Review[] }>(paths.taskReviews(id)); setReviews(d.reviews || []); setReviewError(""); } catch (e: any) { setReviewError("评审队列加载失败:" + e.message); } }, []); const loadApprovals = useCallback(async (taskId?: string) => { const id = taskId || currentTaskRef.current?.id; if (!id) return; try { const d = await api<{ approvals: Approval[] }>(paths.taskApprovals(id)); setApprovals(d.approvals || []); } catch (e: any) { setApprovalMsg("审批队列加载失败:" + e.message); } }, []); const loadPeerMessages = useCallback(async (taskId: string) => { try { const d = await api<{ messages: PeerMessage[] }>(paths.taskMessages(taskId)); setPeerMessages(d.messages || []); } catch { setPeerMessages([]); } }, []); const loadThread = useCallback(async (taskId: string) => { try { const d = await api<{ thread?: ThreadInfo } & ThreadInfo>(paths.taskThread(taskId)); const th = d.thread || d; let turns = th.turns; if (turns === undefined || turns === null) turns = th.turn_count; if (turns === undefined || turns === null) turns = th.turns_count; setThreadText("id " + (th.id || taskId) + " | 名字 " + (th.name || th.thread_name || "") + " | 归档 " + (th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) + " | cwd " + (th.cwd || "") + (turns !== undefined && turns !== null ? " | turn 数 " + turns : "")); } catch (e: any) { setThreadText("线程加载失败:" + e.message); } }, []); const loadThreads = useCallback(async (taskId: string) => { try { const d = await api<{ threads: ThreadInfo[] }>(paths.taskThreads(taskId)); setThreadList(d.threads || []); } catch { setThreadList([]); } }, []); const setThreadGate = useCallback((status: string) => { const ok = ["completed", "failed", "interrupted"].includes(status); setThreadGateNote(ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰"); }, []); const startTrace = useCallback(async (taskId: string, status: string) => { stopTrace(); setTraceEntries([]); setTraceNote(""); setTraceDone(""); if (TRACE_TERMINAL.includes(status)) { try { const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(taskId)); setTraceEntries(d.entries || []); setTraceNote(d.note || ""); setTraceDone(d.status || ""); } catch (e: any) { setTraceNote("链路加载失败:" + e.message); } return; } const ctrl = new AbortController(); traceAbortRef.current = ctrl; let entries: TraceEntry[] = []; const index: Record = {}; let note = ""; let done = false; let doneStatus = ""; try { const headers: Record = {}; const key = getKey(); if (key) headers["X-API-Key"] = key; const r = await fetch(paths.taskTraceStream(taskId), { headers, signal: ctrl.signal }); if (!r.ok) { let msg = "HTTP " + r.status; try { const dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch { /* noop */ } if (r.status === 401) msg += "(key 未填或无效)"; throw new Error(msg); } const reader = r.body!.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); const parts = buf.split("\n\n"); buf = parts.pop() || ""; for (const part of parts) { const line = part.split("\n").filter((l) => l.indexOf("data: ") === 0).map((l) => l.slice(6)).join("\n"); if (!line) continue; try { const m = JSON.parse(line); if (m.type === "snapshot") { entries = m.entries || []; Object.keys(index).forEach((k) => delete index[k]); entries.forEach((e: TraceEntry, i: number) => { if (e.id) index[e.id] = i; }); note = m.note || ""; setTraceEntries([...entries]); setTraceNote(note); scheduleAgents(taskId); } else if (m.type === "entry") { const e = m.entry as TraceEntry; if (e.id && e.id in index) entries[index[e.id]] = e; else { if (e.id) index[e.id] = entries.length; entries.push(e); } setTraceEntries([...entries]); scheduleAgents(taskId); } else if (m.type === "done") { done = true; doneStatus = m.status; setTraceDone(doneStatus); } } catch { /* ignore malformed SSE frame */ } } if (done) break; } } catch (e: any) { if (e && e.name === "AbortError") return; setTraceNote("链路流中断:" + e.message); return; } if (done) { onTasksReload(); } }, [stopTrace, scheduleAgents, onTasksReload]); const showDetail = useCallback(async (taskId: string) => { stopTrace(); try { const t = await api(paths.task(taskId)); setCurrentTask(t); let body = ""; if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n"; if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n"; if (!body) body = "(暂无 result/error 输出)"; setDetailBody(body); setCostBox(""); setForkVisible(false); setForkMsg(""); setPeerMsg(""); setThreadMsg(""); setThreadGate(t.status); // Button gating by role: maintainer/owner for retry/merge/pr, // contributor+ for cancel; the right panel disables with reasons. const role = getRole(t.project_id); const isMaintainer = role === "maintainer" || role === "owner"; const canCreate = role === "contributor" || isMaintainer; const terminal = TRACE_TERMINAL.includes(t.status); const reasons: string[] = []; if (!((["failed", "interrupted", "merge-failed"].includes(t.status)) && isMaintainer)) reasons.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试")); if (!(!terminal && canCreate)) reasons.push("取消:不可用——" + (terminal ? "终态任务不可取消" : "仅项目成员(contributor 及以上)可取消")); if (!(t.status === "completed" && isMaintainer)) reasons.push("合并:不可用——仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并")); if (!(t.status === "merged" && isMaintainer)) reasons.push("提 PR:不可用——仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR")); setActionReasons(reasons.join(";")); setActionMsg(""); void startTrace(taskId, t.status); void loadAgents(taskId); void loadCoordChildren(t); void loadChatHistory(taskId); void loadFollowups(taskId); void loadPeerMessages(taskId); void loadThread(taskId); void loadThreads(taskId); void loadReviews(taskId); void loadApprovals(taskId); } catch (e: any) { setCurrentTask(null); setDetailBody("加载失败:" + e.message); } }, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]); const refreshDetailLists = useCallback(async () => { const t = currentTaskRef.current; if (!t) return; try { const fresh = await api(paths.task(t.id)); setCurrentTask(fresh); let body = ""; if (fresh.result_text) body += "=== result ===\n" + fresh.result_text.slice(0, 8000) + "\n"; if (fresh.error_text) body += "=== error ===\n" + fresh.error_text.slice(0, 8000) + "\n"; setDetailBody(body || "(暂无 result/error 输出)"); } catch { /* light header refresh must not clear the detail */ } void loadFollowups(t.id); void loadChatHistory(t.id); void loadReviews(t.id); void loadApprovals(t.id); void loadCoordChildren(t); void loadAgents(t.id); void loadPeerMessages(t.id); void loadThread(t.id); void loadThreads(t.id); }, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]); const doAction = useCallback(async (kind: string) => { const t = currentTaskRef.current; if (!t) return; const confirmNeeded: Record = { cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)", }; if (confirmNeeded[kind] && !window.confirm(confirmNeeded[kind])) return; try { const r = await api(paths.taskAction(t.id, kind), "POST", {}); setActionMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300)); onTasksReload(); await showDetail(t.id); } catch (e: any) { setActionMsg(kind + " 失败:" + e.message); } }, [showDetail, onTasksReload]); const taskCost = useCallback(async () => { const t = currentTaskRef.current; if (!t) return; try { const c = await api(paths.taskCost(t.id)); setCostBox(JSON.stringify(c, null, 2).slice(0, 4000)); } catch (e: any) { setCostBox("查成本失败:" + e.message); } }, []); const forkTask = useCallback(async (msg: string) => { const t = currentTaskRef.current; if (!t) return; if (!msg.trim()) { setForkMsg("分支消息必填"); return; } if (!TRACE_TERMINAL.includes(t.status)) { setForkMsg("仅终态任务可分支"); return; } try { const nt = await api(paths.taskFork(t.id), "POST", { message: msg.trim() }); setForkMsg("分支任务已建:" + nt.id.slice(0, 8)); onTasksReload(); } catch (e: any) { setForkMsg("分支失败:" + e.message); } }, [onTasksReload]); const postReview = useCallback(async (body: { target_type: string; branch?: string; sha?: string; instructions?: string }) => { const t = currentTaskRef.current; if (!t) return; try { await api(paths.taskReviews(t.id), "POST", body); setReviewMsg("评审已登记(requested)"); await loadReviews(t.id); } catch (e: any) { setReviewMsg("登记评审需求失败:" + e.message); } }, [loadReviews]); const submitFindings = useCallback((reviewId: string) => { const t = currentTaskRef.current; if (!t) return; const text = window.prompt("输入该条评审的 findings(将写回记录并标为 completed):"); if (text === null) return; void api(paths.taskReviewFindings(t.id, reviewId), "POST", { findings: text }) .then(() => void loadReviews(t.id)) .catch((e: any) => setReviewMsg("写 findings 失败:" + e.message)); }, [loadReviews]); const decideApproval = useCallback((approvalId: string, decision: string) => { const t = currentTaskRef.current; if (!t) return; const verb = decision === "approved" ? "approve" : "deny"; void api(paths.taskApprovalDecision(t.id, approvalId, verb), "POST", {}) .then(() => { setApprovalMsg("已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)"); void loadApprovals(t.id); }) .catch((e: any) => setApprovalMsg("决定失败:" + e.message)); }, [loadApprovals]); const postPeer = useCallback(async (mode: string, target: string, text: string) => { const t = currentTaskRef.current; if (!t) return; if (!target.trim()) { setPeerMsg("目标任务 id 不能为空"); return; } if (!text.trim()) { setPeerMsg("消息正文不能为空"); return; } try { const d = await api<{ delivery?: string }>(paths.taskMessagesTo(target.trim()), "POST", { from_task_id: t.id, body: text.trim(), mode }); setPeerMsg("已发送(" + d.delivery + ")"); await loadPeerMessages(t.id); } catch (e: any) { setPeerMsg("发送失败:" + e.message); } }, [loadPeerMessages]); const threadAction = useCallback((kind: string, name: string) => { const t = currentTaskRef.current; if (!t) return; if (!["completed", "failed", "interrupted"].includes(t.status)) { setThreadMsg("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)"); return; } const body: { name?: string } = {}; if (kind === "rename") { if (!name.trim()) { setThreadMsg("新线程名不能为空"); return; } body.name = name.trim(); } void api(paths.taskThreadAction(t.id, kind), "POST", body) .then((r: unknown) => { setThreadMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300)); void loadThread(t.id); void loadThreads(t.id); }) .catch((e: any) => setThreadMsg(kind + " 失败:" + e.message)); }, [loadThread, loadThreads]); const sendChatMessage = useCallback(async (text: string) => { const t = currentTaskRef.current; if (!t) return; const v = text.trim(); if (!v) { setChatMsg("消息不能为空"); return; } try { if (TRACE_TERMINAL.includes(t.status) && t.status !== "merged") { // Message-only continue: all optional fields blank, so the // server keeps the task's original model/effort/approval. await api(paths.taskContinue(t.id), "POST", { message: v }); setChatMsg("已重新排队,同一会话继续"); onTasksReload(); await showDetail(t.id); } else if (["queued", "running"].includes(t.status)) { await api(paths.taskFollowups(t.id), "POST", { body: v, mode: "direct" }); setChatMsg("已直接发送"); await loadFollowups(t.id); await loadChatHistory(t.id); } else { setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息"); } } catch (e: any) { setChatMsg("发送失败:" + e.message); } }, [showDetail, onTasksReload, loadFollowups, loadChatHistory]); const downloadTrace = useCallback(async () => { const t = currentTaskRef.current; if (!t) { setTraceMsg("先选一个任务"); return; } try { const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(t.id)); const blob = new Blob([JSON.stringify({ task_id: t.id, status: d.status, note: d.note || "", count: (d.entries || []).length, entries: d.entries || [] }, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "trace-" + t.id.slice(0, 8) + ".json"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTraceMsg("已下载完整链路 " + (d.entries || []).length + " 条"); } catch (e: any) { setTraceMsg("下载失败:" + e.message); } }, []); useEffect(() => () => stopTrace(), [stopTrace]); return { currentTask, detailBody, actionMsg, actionReasons, costBox, forkVisible, setForkVisible, forkMsg, coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone, traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg, threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory, showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings, decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace, refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(), }; }