From 638f2f64f055a9fe21a4f2135a23931854e62be7 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 24 Jun 2026 17:06:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=BA=E5=B7=A5=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=EF=BC=88takeover=EF=BC=89+=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-③ takeover(⑧ ★ POST /api/tasks/:id/takeover): 后端: - POST /api/tasks/:id/takeover:准备(或复用 result.worktree)任务 worktree, 返回 {taskId, worktree, branch, console}——console 为用户在自己终端起交互式 claude 的命令(daemon 无 TTY,不在进程内起交互会话);在途 run 时拒绝 前端: - api.takeover;TakeoverModal(展示命令 + 一键复制 + 分支提示) - TaskDetail 加「⌨ 人工接管」按钮(onTakeover 经 TaskSection→TaskRow 线程下来) - 顺带:TaskDetail 展示任务附件(📎 文件名,读 task._raw.attachments) 验证:typecheck 干净;206 测试通过;前端 build + dev 渲染 0 错误。 Co-Authored-By: Claude Opus 4.8 --- app/src/api.js | 1 + app/src/app.jsx | 33 ++++++++++++++++++++++++++++- design/ui_kits/console/TaskTree.jsx | 26 +++++++++++++++++------ src/api/server.ts | 20 ++++++++++++++++- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 1f1a253..9563dad 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -31,6 +31,7 @@ export const api = { createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body), patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body), createProject: (body) => http('POST', '/api/projects', body), + takeover: (taskId) => http('POST', `/api/tasks/${taskId}/takeover`), // 附件上传走 multipart(不能用 JSON helper,content-type 由浏览器带 boundary) uploadAttachments: async (taskId, files) => { const fd = new FormData(); diff --git a/app/src/app.jsx b/app/src/app.jsx index 9993736..d1a912b 100644 --- a/app/src/app.jsx +++ b/app/src/app.jsx @@ -103,6 +103,29 @@ function StreamModal({ agent, onClose }) { ); } +// 接管模态:展示 daemon 准备好的 worktree + 在自己终端起交互式 claude 的命令 +function TakeoverModal({ info, onClose }) { + const [copied, setCopied] = React.useState(false); + const copy = () => { try { navigator.clipboard.writeText(info.console); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 非安全上下文 */ } }; + return ( +
+
+
+
⌨ 人工接管 · {info.title}
+
worktree 已就绪。在你自己的终端运行下面命令,起交互式 Claude Code 手动接手:
+
+ {info.console} + +
+
分支 {info.branch} · 完成后用 git 提交,可经审核区合并。
+
+ +
+
+
+ ); +} + export function App() { const I18N = window.MAESTRO_I18N; const [lang, setLang] = React.useState(() => { @@ -125,6 +148,7 @@ export function App() { const [showConfig, setShowConfig] = React.useState(false); const [archiveItem, setArchiveItem] = React.useState(null); const [streamAgent, setStreamAgent] = React.useState(null); + const [takeoverInfo, setTakeoverInfo] = React.useState(null); const [toasts, setToasts] = React.useState([]); const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark'); React.useEffect(() => { @@ -281,6 +305,12 @@ export function App() { setShowConfig(false); toast('ok', t.toastSaved); loadGlobal(); } catch (e) { toast('warn', '保存失败:' + e.message); } }; + const takeover = async (task) => { + try { + const info = await api.takeover(task.id); + setTakeoverInfo({ ...info, title: task.title }); + } catch (e) { toast('warn', '接管失败:' + e.message); } + }; const [showNewProject, setShowNewProject] = React.useState(false); const createProject = async (body) => { try { @@ -312,7 +342,7 @@ export function App() { {tasks.length ? ( - + ) : (
{t.empty} @@ -325,6 +355,7 @@ export function App() { {archiveItem ? setArchiveItem(null)} /> : null} {showNewProject ? setShowNewProject(false)} /> : null} {streamAgent ? setStreamAgent(null)} /> : null} + {takeoverInfo ? setTakeoverInfo(null)} /> : null}
{toasts.map((x) => {x.text})}
diff --git a/design/ui_kits/console/TaskTree.jsx b/design/ui_kits/console/TaskTree.jsx index e438b75..8434fd3 100644 --- a/design/ui_kits/console/TaskTree.jsx +++ b/design/ui_kits/console/TaskTree.jsx @@ -100,7 +100,7 @@ function FilterBar({ t, kw, setKw, cplxSet, toggleCplx, statusSet, toggleGroup, ); } -function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet }) { +function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet, onTakeover }) { const { StatusChip } = window.MaestroDesignSystem_a6a290; const kids = (task.children || []).filter((k) => !visibleSet || visibleSet.has(k.id)); const open = openIds.has(task.id) || !!visibleSet; // 筛选时自动展开可见节点 @@ -140,13 +140,13 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
- {expanded ? : null} + {expanded ? : null} {kids.length && open ? (
{kids.map((k) => ( + byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} /> ))}
) : null} @@ -154,11 +154,25 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO ); } -function TaskDetail({ task, t, byId, onJump }) { +function TaskDetail({ task, t, byId, onJump, onTakeover }) { const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null; const text = task.doc || task.ops; + const attachments = (task._raw && task._raw.attachments) || []; return (
+ {onTakeover || attachments.length ? ( +
+ {onTakeover ? ( + + ) : null} + {attachments.length ? ( + 📎 {attachments.map((a) => a.name).join(' · ')} + ) : null} +
+ ) : null} {label ? (
{label}
@@ -253,7 +267,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) { ); } -function TaskSection({ tasks, onToast, onCreate, t }) { +function TaskSection({ tasks, onToast, onCreate, onTakeover, t }) { const { Button, SectionHead } = window.MaestroDesignSystem_a6a290; const [expandedId, setExpandedId] = React.useState(null); const [openIds, setOpenIds] = React.useState(() => new Set(['t1'])); @@ -333,7 +347,7 @@ function TaskSection({ tasks, onToast, onCreate, t }) { {roots.map((tk) => ( + byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} /> ))}
diff --git a/src/api/server.ts b/src/api/server.ts index cda3f92..5ef6549 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -9,7 +9,7 @@ import { syncProject, hasTodoJson } from '../sync/todo-sync.js'; import { resolvedExecutorModels } from '../executor/models.js'; import { classifyComplexity, type ClassifierFn } from '../executor/classify.js'; import { mergeBranch } from '../executor/merge.js'; -import { git, removeWorktree } from '../executor/worktree.js'; +import { git, removeWorktree, createWorktree } from '../executor/worktree.js'; import { cleanupTaskRunArtifacts } from '../executor/cleanup.js'; import { resolveLogo, LOGO_MIME } from './logo.js'; import { readTranscript, TranscriptError } from '../executor/transcript.js'; @@ -248,6 +248,24 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return { attachments: store.addAttachments(id, saved) }; }); + // 人工接管:准备(或复用)任务 worktree,返回用户在自己终端起交互式 claude 的命令。 + // 适用卡住/需人工的任务——人接手手动改。不在 daemon 内起交互会话(无 TTY)。 + app.post('/api/tasks/:id/takeover', async (req, reply) => { + const { id } = req.params as { id: string }; + const task = store.getTask(id); + if (!task) return reply.code(404).send({ error: `任务不存在: ${id}` }); + if (store.hasInflightRun(id)) return reply.code(400).send({ error: '任务有在途 run,无法接管' }); + const project = store.getProject(task.projectId); + if (!project) return reply.code(404).send({ error: `项目不存在: ${task.projectId}` }); + let worktree = task.result?.worktree ?? null; + let branch = task.result?.branch ?? null; + if (!worktree) { + const wt = await createWorktree(project.repoPath, id, project.defaultBranch); + worktree = wt.dir; branch = wt.branch; + } + return { taskId: id, worktree, branch, console: `cd ${worktree} && claude` }; + }); + // 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask) app.patch('/api/tasks/:id', (req) => { const { id } = req.params as { id: string };