feat: 人工接管任务(takeover)+ 任务详情展示附件

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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 17:06:35 +08:00
parent a688b6977e
commit 638f2f64f0
4 changed files with 72 additions and 8 deletions
+1
View File
@@ -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 helpercontent-type 由浏览器带 boundary
uploadAttachments: async (taskId, files) => {
const fd = new FormData();
+32 -1
View File
@@ -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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
<div style={{ position: 'relative', width: 'min(620px,92vw)', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-lg,10px)', padding: 18, boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--cyan)', marginBottom: 12 }}> 人工接管 · {info.title}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8 }}>worktree 已就绪在你自己的终端运行下面命令起交互式 Claude Code 手动接手</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
<code style={{ flex: 1, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--cyan-dim)', borderRadius: 4, padding: '10px 12px', fontSize: 12.5, color: 'var(--ink)', wordBreak: 'break-all' }}>{info.console}</code>
<button onClick={copy} style={{ flex: 'none', fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: copied ? 'var(--green)' : 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 4, padding: '0 12px' }}>{copied ? '✓ 已复制' : '复制'}</button>
</div>
<div style={{ fontSize: 11, color: 'var(--faint)', marginTop: 10 }}>分支 {info.branch} · 完成后用 git 提交,可经审核区合并</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 4, padding: '5px 14px', cursor: 'pointer' }}>关闭</button>
</div>
</div>
</div>
);
}
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() {
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
{tasks.length ? (
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} t={t} />
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} t={t} />
) : (
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
@@ -325,6 +355,7 @@ export function App() {
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
{streamAgent ? <StreamModal agent={streamAgent} onClose={() => setStreamAgent(null)} /> : null}
{takeoverInfo ? <TakeoverModal info={takeoverInfo} onClose={() => setTakeoverInfo(null)} /> : null}
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
</div>
+20 -6
View File
@@ -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
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
<StatusChip status={task.status} label={t.status[task.status]} />
</div>
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} /> : null}
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} /> : null}
{kids.length && open ? (
<div style={{ marginLeft: 22 }}>
{kids.map((k) => (
<TaskRow key={k.id} task={k} depth={depth + 1} ctx={visibleSet ? visibleSet.ctx.has(k.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} />
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} />
))}
</div>
) : 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 (
<div style={{ background: 'var(--panel)', borderBottom: '1px solid var(--line)', borderLeft: '2px solid var(--green-dim)', padding: '14px 16px', animation: 'maestro-rise .2s ease both', display: 'grid', gap: 14 }}>
{onTakeover || attachments.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{onTakeover ? (
<button onClick={(e) => { e.stopPropagation(); onTakeover(task); }}
style={{ fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 10px' }}>
人工接管
</button>
) : null}
{attachments.length ? (
<span style={{ fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span>
) : null}
</div>
) : null}
{label ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
@@ -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) => (
<TaskRow key={tk.id} task={tk} depth={0} ctx={visibleSet ? visibleSet.ctx.has(tk.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} />
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} />
))}
</div>
</section>
+19 -1
View File
@@ -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/complexitycomplexity 重置逻辑在 Store.patchTask
app.patch('/api/tasks/:id', (req) => {
const { id } = req.params as { id: string };