New:Socket for Asana Is Now Available.Learn more
Get Started

dsh-graph

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dsh-graph - npm Package Compare versions

Comparing version
0.5.2
to
0.6.1
+519
core/version-lane.js
/** 版本泳道管理:创建/重命名/删除版本泳道(g-134)+ 发布 guard(g-135)。 */
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
import { join, basename } from "node:path";
import { randomUUID } from "node:crypto";
import { parseDoc, serializeDoc, sectionText } from "./model.js";
import { appendEvent, readEvents, nowIso } from "./events.js";
import { GraphError } from "./machine.js";
// ---- 辅助函数 ----
/** 读取版本元数据文件 */
function loadVersionMeta(root, slug) {
const vfile = join(root, "versions", slug, "version.md");
if (!existsSync(vfile))
throw new GraphError(`版本 ${slug} 不存在`);
const doc = parseDoc(readFileSync(vfile, "utf8"));
return doc.meta;
}
/** 保存版本元数据文件 */
function saveVersionMeta(root, slug, meta) {
const vfile = join(root, "versions", slug, "version.md");
const doc = parseDoc(readFileSync(vfile, "utf8"));
doc.meta = meta;
writeFileSync(vfile, serializeDoc(doc), "utf8");
}
/** 检查版本目录下是否有任何目标(含归档)
* 覆盖所有历史位置:versions/<slug>/goals、versions/<slug>/archived、全局 versions/archived */
function versionHasGoals(root, slug) {
const vdir = join(root, "versions", slug);
// 检查 goals/ 目录
const goalsDir = join(vdir, "goals");
if (existsSync(goalsDir)) {
const entries = readdirSync(goalsDir).filter((d) => !d.startsWith("."));
// 检查每个目录是否包含 goal.md 文件
for (const entry of entries) {
const goalFile = join(goalsDir, entry, "goal.md");
if (existsSync(goalFile))
return true;
}
}
// 检查 versions/<slug>/archived/ 目录
const archivedDir = join(vdir, "archived");
if (existsSync(archivedDir)) {
const entries = readdirSync(archivedDir).filter((d) => !d.startsWith("."));
// 检查每个目录是否包含 goal.md 文件
for (const entry of entries) {
const goalFile = join(archivedDir, entry, "goal.md");
if (existsSync(goalFile))
return true;
}
}
// 检查全局 versions/archived/ 目录(历史位置)
const globalArchivedDir = join(root, "versions", "archived");
if (existsSync(globalArchivedDir)) {
const entries = readdirSync(globalArchivedDir).filter((d) => !d.startsWith("."));
for (const entry of entries) {
const goalFile = join(globalArchivedDir, entry, "goal.md");
if (!existsSync(goalFile))
continue;
try {
const doc = parseDoc(readFileSync(goalFile, "utf8"));
if (doc.meta.version === slug)
return true;
}
catch {
/* 坏目标文件跳过 */
}
}
}
return false;
}
/** 批量更新版本内所有目标的 version 字段
* 覆盖所有历史位置:versions/<slug>/goals、versions/<slug>/archived、全局 versions/archived */
function updateGoalsVersionField(root, slug, newSlug) {
const vdir = join(root, "versions", slug);
// 更新 goals/ 下的目标
const goalsDir = join(vdir, "goals");
if (existsSync(goalsDir)) {
for (const goalId of readdirSync(goalsDir)) {
const gf = join(goalsDir, goalId, "goal.md");
if (!existsSync(gf))
continue;
try {
const doc = parseDoc(readFileSync(gf, "utf8"));
if (doc.meta.version === slug) {
doc.meta.version = newSlug;
writeFileSync(gf, serializeDoc(doc), "utf8");
}
}
catch {
/* 坏目标文件跳过 */
}
}
}
// 更新 versions/<slug>/archived/ 下的目标
const archivedDir = join(vdir, "archived");
if (existsSync(archivedDir)) {
for (const goalId of readdirSync(archivedDir)) {
const gf = join(archivedDir, goalId, "goal.md");
if (!existsSync(gf))
continue;
try {
const doc = parseDoc(readFileSync(gf, "utf8"));
if (doc.meta.version === slug) {
doc.meta.version = newSlug;
writeFileSync(gf, serializeDoc(doc), "utf8");
}
}
catch {
/* 坏目标文件跳过 */
}
}
}
// 更新全局 versions/archived/ 下的目标(历史位置)
const globalArchivedDir = join(root, "versions", "archived");
if (existsSync(globalArchivedDir)) {
for (const goalId of readdirSync(globalArchivedDir)) {
const gf = join(globalArchivedDir, goalId, "goal.md");
if (!existsSync(gf))
continue;
try {
const doc = parseDoc(readFileSync(gf, "utf8"));
if (doc.meta.version === slug) {
doc.meta.version = newSlug;
writeFileSync(gf, serializeDoc(doc), "utf8");
}
}
catch {
/* 坏目标文件跳过 */
}
}
}
}
// ---- 公开 API ----
/** 创建版本泳道:创建版本目录与 version.md,记录 version.created 事件。
* 校验:slug 非空且不含路径分隔符;版本目录不能已存在。
* 事件先行:先写事件,再创建目录和文件;若事件写入失败则不创建任何内容。 */
export function createVersion(root, opts) {
const slug = opts.slug.trim();
if (!slug)
throw new GraphError("版本 slug 不能为空");
if (slug.includes("/") || slug.includes("\\"))
throw new GraphError("版本 slug 不能包含路径分隔符");
if (slug.includes("\0"))
throw new GraphError("版本 slug 不能包含 NUL 字符");
const vdir = join(root, "versions", slug);
if (existsSync(vdir))
throw new GraphError(`版本 ${slug} 已存在`);
const name = opts.name?.trim() || slug;
const vId = "v-" + randomUUID().slice(0, 8);
const createdAt = nowIso();
// R-02:事件先行——先写事件,再创建目录和文件
// 事件 details 包含完整元数据,使 rebuild/replay 可恢复版本泳道
const eventDetails = {
version: slug,
name,
version_id: vId,
status: "planning",
created_at: createdAt,
implicit: false,
};
appendEvent(root, {
actor: opts.actor,
event: "version.created",
details: eventDetails,
});
// 创建版本目录
mkdirSync(join(vdir, "goals"), { recursive: true });
// 创建 version.md
const meta = {
id: vId,
name,
status: "planning",
created_at: createdAt,
};
const body = "\n## 范围\n\n(手动创建的版本泳道)\n";
const doc = { meta, body };
writeFileSync(join(vdir, "version.md"), serializeDoc(doc), "utf8");
return { slug, name };
}
/** 重命名版本泳道:更新版本目录名、version.md 的 name 字段、所有目标的 version 引用,记录 version.renamed 事件。
* 校验:版本必须存在;新 slug 非空且不含路径分隔符;新 slug 不能已存在(除非与旧 slug 相同,此时仅更新 name)。
* 版本目录、目标 version 引用、事件记录保持一致且可追溯。
* 预检先行:先完成所有可能失败的校验,再写事件,再执行变更。 */
export function renameVersion(root, opts) {
const slug = opts.slug.trim();
if (!slug)
throw new GraphError("版本 slug 不能为空");
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
const oldMeta = loadVersionMeta(root, slug);
const oldName = String(oldMeta.name ?? slug);
const newSlug = opts.newSlug?.trim() || slug;
const newName = opts.newName?.trim() || oldName;
if (newSlug.includes("/") || newSlug.includes("\\"))
throw new GraphError("新版本 slug 不能包含路径分隔符");
if (newSlug.includes("\0"))
throw new GraphError("新版本 slug 不能包含 NUL 字符");
// 预检:如果 slug 变了,先检查新 slug 是否已存在(避免写事件后失败留下假事件)
const slugChanged = newSlug !== slug;
if (slugChanged) {
const newVdir = join(root, "versions", newSlug);
if (existsSync(newVdir))
throw new GraphError(`版本 ${newSlug} 已存在`);
}
// R-02:事件先行——预检通过后写事件,再执行变更
const eventDetails = {
old_slug: slug,
new_slug: newSlug,
old_name: oldName,
new_name: newName,
version_id: oldMeta.id,
old_status: oldMeta.status,
new_status: oldMeta.status, // 重命名不改变状态
};
appendEvent(root, {
actor: opts.actor,
event: "version.renamed",
details: eventDetails,
});
// 执行变更
if (slugChanged) {
// 先更新所有目标的 version 字段
updateGoalsVersionField(root, slug, newSlug);
// 移动版本目录
renameSync(vdir, join(root, "versions", newSlug));
// 更新 version.md 的 name
saveVersionMeta(root, newSlug, { ...oldMeta, name: newName });
}
else {
// 仅更新 name
saveVersionMeta(root, slug, { ...oldMeta, name: newName });
}
return { old_slug: slug, new_slug: newSlug, old_name: oldName, new_name: newName };
}
/** 删除版本泳道:仅删除完全空的版本泳道(含归档目录在内均无目标),记录 version.deleted 事件。
* 校验:版本必须存在;版本下不能有任何目标(含归档);backlog 与独立目标不是版本,不允许删除。
* 保守删除:递归预检 goals/archived 下的结构与内容,确认完全可删后才写事件;
* nested orphan 拒绝时不得写假删除事件。删除事件包含完整版本元数据快照。 */
export function deleteVersion(root, opts) {
const slug = opts.slug.trim();
if (!slug)
throw new GraphError("版本 slug 不能为空");
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
// 读取版本元数据快照(在删除前)
const versionMeta = loadVersionMeta(root, slug);
// 检查是否为空版本(含归档目录)
if (versionHasGoals(root, slug)) {
throw new GraphError(`版本 ${slug} 下仍有目标(含归档目标),不能删除——请先移走或删除所有目标`);
}
// 保守删除:根级目录检查
const vdirEntries = readdirSync(vdir);
const allowedEntries = ["goals", "archived", "version.md"];
const orphanEntries = vdirEntries.filter((entry) => !allowedEntries.includes(entry));
if (orphanEntries.length > 0) {
throw new GraphError(`版本 ${slug} 下存在未知内容(${orphanEntries.join(", ")}),不能删除——请先清理`);
}
// 递归预检 goals/ 子目录——确认完全可删后才写事件
const goalsDir = join(vdir, "goals");
if (existsSync(goalsDir)) {
for (const entry of readdirSync(goalsDir)) {
const entryPath = join(goalsDir, entry);
let innerEntries;
try {
innerEntries = readdirSync(entryPath);
}
catch {
// ENOTDIR = 非目录文件,视为 orphan
throw new GraphError(`版本 ${slug}/goals/ 下存在非目录文件(${entry}),不能删除——请先清理`);
}
const allowedInner = ["goal.md", "cards"];
const orphanInner = innerEntries.filter((e) => !allowedInner.includes(e));
if (orphanInner.length > 0) {
throw new GraphError(`版本 ${slug}/goals/${entry} 下存在未知内容(${orphanInner.join(", ")}),不能删除——请先清理`);
}
const cardsDir = join(entryPath, "cards");
if (existsSync(cardsDir) && readdirSync(cardsDir).length > 0) {
throw new GraphError(`版本 ${slug}/goals/${entry}/cards 下仍有内容,不能删除——请先清理`);
}
}
}
// 递归预检 archived/ 子目录
const archivedDir = join(vdir, "archived");
if (existsSync(archivedDir)) {
for (const entry of readdirSync(archivedDir)) {
const entryPath = join(archivedDir, entry);
let innerEntries;
try {
innerEntries = readdirSync(entryPath);
}
catch {
throw new GraphError(`版本 ${slug}/archived/ 下存在非目录文件(${entry}),不能删除——请先清理`);
}
const allowedInner = ["goal.md", "cards"];
const orphanInner = innerEntries.filter((e) => !allowedInner.includes(e));
if (orphanInner.length > 0) {
throw new GraphError(`版本 ${slug}/archived/${entry} 下存在未知内容(${orphanInner.join(", ")}),不能删除——请先清理`);
}
const cardsDir = join(entryPath, "cards");
if (existsSync(cardsDir) && readdirSync(cardsDir).length > 0) {
throw new GraphError(`版本 ${slug}/archived/${entry}/cards 下仍有内容,不能删除——请先清理`);
}
}
}
// 所有预检通过后才写事件
appendEvent(root, {
actor: opts.actor,
event: "version.deleted",
details: {
version: slug,
version_id: versionMeta.id,
name: versionMeta.name,
status: versionMeta.status,
created_at: versionMeta.created_at,
deleted_version_dir: `versions/${slug}`,
had_goals: existsSync(goalsDir) && readdirSync(goalsDir).length > 0,
had_archived: existsSync(archivedDir) && readdirSync(archivedDir).length > 0,
},
});
// 精确非递归删除
// 先删除 goals/ 子目录
if (existsSync(goalsDir)) {
for (const entry of readdirSync(goalsDir)) {
const entryPath = join(goalsDir, entry);
// 删除 cards/ 子目录(已确认为空)
const cardsDir = join(entryPath, "cards");
if (existsSync(cardsDir))
rmdirSync(cardsDir);
// 删除 goal.md
const goalFile = join(entryPath, "goal.md");
if (existsSync(goalFile))
rmSync(goalFile);
// 删除目标子目录
rmdirSync(entryPath);
}
rmdirSync(goalsDir);
}
// 删除 archived/ 子目录
if (existsSync(archivedDir)) {
for (const entry of readdirSync(archivedDir)) {
const entryPath = join(archivedDir, entry);
const cardsDir = join(entryPath, "cards");
if (existsSync(cardsDir))
rmdirSync(cardsDir);
const goalFile = join(entryPath, "goal.md");
if (existsSync(goalFile))
rmSync(goalFile);
rmdirSync(entryPath);
}
rmdirSync(archivedDir);
}
// 删除 version.md
const vfile = join(vdir, "version.md");
if (existsSync(vfile))
rmSync(vfile);
// 删除版本根目录(应为空)
const remainingEntries = readdirSync(vdir);
if (remainingEntries.length > 0) {
throw new GraphError(`版本 ${slug} 根目录下存在未知内容(${remainingEntries.join(", ")}),不能删除——请先清理`);
}
rmdirSync(vdir);
return { slug };
}
// ---- g-135: 版本发布 guard 与版本状态管理 ----
/** 校验版本内全部非归档目标均为 delivered;返回阻塞目标列表(空 = 可发布)。 */
export function validateVersionRelease(root, slug) {
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
const blocking = [];
const goalsDir = join(vdir, "goals");
if (existsSync(goalsDir)) {
for (const entry of readdirSync(goalsDir)) {
const gf = join(goalsDir, entry, "goal.md");
if (!existsSync(gf))
continue;
try {
const doc = parseDoc(readFileSync(gf, "utf8"));
if (doc.meta.archived === true)
continue; // 跳过归档
if (doc.meta.status !== "delivered") {
blocking.push({
id: String(doc.meta.id ?? entry),
title: String(doc.meta.title ?? entry),
status: String(doc.meta.status ?? "unknown"),
});
}
}
catch {
/* 坏目标文件跳过 */
}
}
}
return blocking;
}
/** 发布版本:由负责人确认后将版本标为 released。
* 校验:全部非归档目标必须为 delivered;否则返回阻塞清单、不写任何状态。
* 事件先行:先写 version.released 事件,再更新 version.md 的 status。
* actor 必须是 human:* 或 supervisor:* 前缀(负责人确认路径),agent:* 被拒绝。 */
export function releaseVersion(root, opts) {
const slug = opts.slug.trim();
if (!slug)
throw new GraphError("版本 slug 不能为空");
// 校验 actor 身份:仅 human:* 或 supervisor:* 可发布
if (!opts.actor.startsWith("human:") && !opts.actor.startsWith("supervisor:")) {
throw new GraphError(`发布操作仅限负责人确认(actor=${opts.actor}),执行子代理不能直接发布`);
}
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
const meta = loadVersionMeta(root, slug);
if (meta.status === "released") {
throw new GraphError(`版本 ${slug} 已经是 released 状态`);
}
// 校验发布前置条件:全部非归档目标必须为 delivered
const blocking = validateVersionRelease(root, slug);
if (blocking.length > 0) {
return { ok: false, blocking };
}
// R-02:事件先行——先写 version.released 事件,再更新版本状态
appendEvent(root, {
actor: opts.actor,
event: "version.released",
details: {
version: slug,
version_id: meta.id,
name: meta.name,
old_status: meta.status,
new_status: "released",
},
});
// 更新 version.md 的 status
saveVersionMeta(root, slug, { ...meta, status: "released" });
return { ok: true };
}
/** 合法的 setVersionStatus 目标状态(不含 released——released 只能经 releaseVersion)。 */
const VERSION_STATUS_ALLOWLIST = ["planning", "active"];
/** 设置版本状态(working → active / active → planning 等)。
* released 只能经 releaseVersion 路径,此函数拒绝。
* 事件先行:先写 version.status_changed 事件,再更新 version.md。 */
export function setVersionStatus(root, opts) {
const slug = opts.slug.trim();
if (!slug)
throw new GraphError("版本 slug 不能为空");
const newStatus = opts.status.trim();
// released 只能经 releaseVersion(含 delivered-goal guard),不能走此直接路径
if (newStatus === "released") {
throw new GraphError("不能通过 setVersionStatus 直接设为 released——请使用 releaseVersion(含发布前置校验)");
}
// allowlist:只接受合法的非 released 状态
if (!VERSION_STATUS_ALLOWLIST.includes(newStatus)) {
throw new GraphError(`非法版本状态:${newStatus}(合法值:${VERSION_STATUS_ALLOWLIST.join(", ")})`);
}
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
const meta = loadVersionMeta(root, slug);
const oldStatus = meta.status;
if (oldStatus === newStatus)
return; // 幂等
// released 是终态——不允许回退到 planning/active
if (oldStatus === "released") {
throw new GraphError("版本已 released,不能回退到其他状态——released 是终态");
}
// R-02:事件先行
appendEvent(root, {
actor: opts.actor,
event: "version.status_changed",
details: {
version: slug,
version_id: meta.id,
old_status: oldStatus,
new_status: newStatus,
},
});
saveVersionMeta(root, slug, { ...meta, status: newStatus });
}
/** 读取版本详情:元数据 + 范围/摘要(从 version.md body 的「范围」小节提取)。 */
export function versionDetail(root, slug) {
const vdir = join(root, "versions", slug);
if (!existsSync(vdir))
throw new GraphError(`版本 ${slug} 不存在`);
const vfile = join(vdir, "version.md");
const doc = parseDoc(readFileSync(vfile, "utf8"));
const meta = doc.meta;
// 从 body 提取「范围」小节内容作为摘要/范围
const scopeRaw = sectionText(doc.body, "范围");
const scope = scopeRaw ? scopeRaw.replace(/<!--[\s\S]*?-->/g, "").trim() || null : null;
// 统计非归档目标数
let goalsCount = 0;
const goalsDir = join(vdir, "goals");
if (existsSync(goalsDir)) {
for (const entry of readdirSync(goalsDir)) {
const gf = join(goalsDir, entry, "goal.md");
if (!existsSync(gf))
continue;
try {
const gdoc = parseDoc(readFileSync(gf, "utf8"));
if (gdoc.meta.archived !== true)
goalsCount++;
}
catch { /* 跳过 */ }
}
}
// 发布前置校验(非 released 状态时检查阻塞清单)
const blocking = meta.status === "released" ? [] : validateVersionRelease(root, slug);
return {
slug,
id: meta.id ?? null,
name: String(meta.name ?? slug),
status: String(meta.status ?? "unknown"),
created_at: String(meta.created_at ?? ""),
summary: scope,
scope,
goals_count: goalsCount,
blocking,
};
}
// dsh-graph — 浏览器半边(npm 包名 = dsh-graph;内部 host 插件 id 保留 dsh-graph-host):手写 classic script,零构建。
// 二维泳道看板。视觉约定:卡片类型用「粗左边框 + 颜色 + 图标」区分;
// 依赖关系用琥珀色左边框 + 「⛓ 等待」标识;详情走 modal 弹窗;事件话术人类化。
window.__ModuleLoader__.load({
id: "dsh-graph",
factory(require) {
const React = require("react");
const h = React.createElement;
if (blocked) {
return h("div", { style: { ...S.statusLine, color: "#d66" } }, "⛔ " + text);
}
const animClass = running ? "dg-running-flow" : "";
return h(
"div", { className: animClass, style: { ...S.statusLine, marginTop: 3 } },
h("span", { className: running ? "dg-icon-pulse" : "" }, "⏳ "),
text,
);
}
// 上下文抽屉:摘要 + 全文 + 子代理 id/链接 + g-109 收集提示词编辑 + g-128 删除按钮
function CardDrawer(props) {
const [state, setState] = React.useState({ loading: true });
const [promptText, setPromptText] = React.useState("");
const [collectNote, setCollectNote] = React.useState(null);
const [collecting, setCollecting] = React.useState(false);
const [relaunchRoute, setRelaunchRoute] = React.useState(null); // g-109:最近一次重新收集的模型路由
// g-128:删除确认状态
const [deleteConfirm, setDeleteConfirm] = React.useState(false);
const [deleteIdInput, setDeleteIdInput] = React.useState("");
const [deleteNote, setDeleteNote] = React.useState(null);
React.useEffect(() => {
let alive = true;
fetch(graphUrl("/api/dsh-graph/goal", { id: props.goalId }))
.then((r) => r.json())
.then((data) => alive && setState({ loading: false, data }))
.catch((e) => alive && setState({ loading: false, error: String(e) }));
return () => { alive = false; };
}, [props.goalId]);
let inner;
if (state.loading) inner = "加载中…";
else if (state.error) inner = "获取失败:" + state.error;
else {
const card = (state.data.cards ?? []).find((c) => c.id === props.cardId);
if (!card) inner = "卡片不存在:" + props.cardId;
else {
// g-145:生成完整的收集提示词,注入仓库根、goal/card 元数据、回填模板和禁区
const goalTitle = state.data.meta?.title ?? props.goalId;
const cardTitle = card.title;
const cardKind = card.kind ?? "text";
const root = state.data.root ?? "(仓库根未知)";
// 可编辑的收集信息目标部分
const editablePart = [
`## 收集任务上下文`,
``,
`**收集范围**:`,
`请收集与卡片「${cardTitle}」相关的详细上下文信息,用于填充该卡片。`,
].join("\n");
// 只读的规范约束部分
const readonlyPart = [
``,
`**工作目录**:当前分配的 worktree/当前工作目录(不要猜测 .dsh-graph 文件路径)`,
``,
`**目标信息**:`,
`- id: \`${props.goalId}\``,
`- 标题: ${goalTitle}`,
``,
`**卡片信息**:`,
`- id: \`${card.id}\``,
`- 标题: ${cardTitle}`,
`- 类型: ${cardKind}`,
``,
`**回填要求**:`,
`1. 全文写进 \`text\` 参数`,
`2. \`summary\` 写一句话要点式摘要(≤100 字左右),不要长文`,
`3. 完成后必须调用以下精确命令回填结果:`,
`\`\`\``,
`graph_fill_card(goal="${props.goalId}", card="${card.id}", text=<全文>, summary=<≤100字摘要>)`,
`\`\`\``,
``,
`**禁区(严格遵守)**:`,
`1. 不得猜测 \`.dsh-graph\` 文件路径——所有路径已在上方提供`,
`2. 不得修改其他 goal 或 card——只能回填当前绑定的卡片 \`${card.id}\``,
`3. 不得自行调用 \`graph_review_card\`——完成后由 supervisor 复核`,
`4. 所有 graph 工具操作必须在当前分配的 worktree/当前工作目录下运行`,
].join("\n");
const autoPrompt = editablePart + readonlyPart;
const childLink = card.child_id
? h("div", { style: S.drawerSection, key: "child" },
h("div", { style: { ...S.drawerH, display: "flex", alignItems: "center", justifyContent: "space-between" } },
"🤖 收集子代理",
card.parent_session_id
? h("button", {
style: S.btn,
className: "dg-btn",
onClick: () => { openChildSession(card.parent_session_id, card.child_id); },
}, "↗ 转到对话")
: null),
h("div", { style: S.meta }, `id:${card.child_id}`))
: null;
// g-109:收集提示词编辑区(空卡片显示)
const collectPanel = card.status === "empty" || card.status === "collecting"
? h("div", { style: S.drawerSection, key: "collect", className: "dg-collect-prompt" },
h("div", { style: S.drawerH }, "📝 收集提示词"),
// 可编辑的收集信息目标部分
h("div", { style: { marginTop: 4, marginBottom: 8 } },
h("div", { style: { fontWeight: 600, fontSize: 12, opacity: 0.9, marginBottom: 4 } }, "收集信息目标(可编辑)"),
h("textarea", {
style: { ...S.promptInput, width: "100%", minHeight: 150, resize: "vertical", marginTop: 2 },
value: promptText ? promptText.split(readonlyPart)[0] : editablePart,
onChange: (e) => {
const newEditable = e.target.value;
setPromptText(newEditable + readonlyPart);
},
})),
// 只读的规范约束部分
h("div", { style: { marginTop: 4, marginBottom: 8 } },
h("div", { style: { fontWeight: 600, fontSize: 12, opacity: 0.9, marginBottom: 4 } }, "规范约束(只读)"),
h("div", {
style: {
...S.promptInput,
width: "100%",
minHeight: 80,
maxHeight: 200,
overflowY: "auto",
marginTop: 2,
whiteSpace: "pre-wrap",
opacity: 0.7,
pointerEvents: "none",
userSelect: "none",
},
}, readonlyPart)),
h("button", {
style: { ...S.btn, marginTop: 6, padding: "4px 14px" }, className: "dg-btn",
disabled: collecting,
onClick: async () => {
setCollecting(true);
setCollectNote("派发中…");
try {
const r = await fetch(graphUrl("/api/dsh-graph/start-collection"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
goal: props.goalId,
card: props.cardId,
prompt: promptText || autoPrompt,
}),
});
const data = await r.json();
if (data.ok) {
if (data.child_error) {
setCollectNote("⚠️ 子代理启动失败:" + data.child_error);
} else if (data.child_id) {
setCollectNote("✅ 已派发收集子代理,id:" + data.child_id);
} else {
setCollectNote("⚠️ 子代理未启动(无 child_id)");
}
} else {
setCollectNote("⚠️ 派发失败:" + (data.error || "未知错误"));
}
} catch (e) {
setCollectNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setCollecting(false);
},
}, "开始收集"),
collectNote ? h("div", { style: { ...S.meta, marginTop: 4 } }, collectNote) : null)
: null;
// g-154: 卡片文件入口(复用 goal.md 同类的 file-link/open-file 机制)
const cardFileEntry = card.cardFile
? h("div", { key: "f", style: { ...S.drawerSection, display: "flex", alignItems: "center", gap: 6 } },
h("span", { style: { fontSize: 11, opacity: 0.7 } }, "📄 卡片文件"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" },
className: "dg-btn",
title: "用系统默认编辑器打开卡片文件",
onClick: async (e) => {
e.stopPropagation();
try {
const conn = connectionRt ?? appCtx?.get?.("connection");
if (conn?.api?.host?.openPath) {
const result = await conn.api.host.openPath({ path: card.cardFile });
if (result?.opened) { showToast("✅ 已打开卡片文件"); return; }
}
await copyText(card.cardFile);
showToast("✅ 路径已复制(打开不可用)");
} catch {
await copyText(card.cardFile);
showToast("✅ 路径已复制");
}
},
}, "打开"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" },
className: "dg-btn",
title: "复制卡片文件路径",
onClick: async (e) => { e.stopPropagation(); const ok = await copyText(card.cardFile); if (ok) showToast("✅ 路径已复制"); },
}, "复制路径"))
: h("div", { key: "f", style: { ...S.drawerSection, display: "flex", alignItems: "center", gap: 6, opacity: 0.5 } },
h("span", { style: { fontSize: 11 } }, "📄 卡片文件"),
h("span", { style: { fontSize: 11 } }, "(无文件路径)"));
inner = [
h("div", { key: "t", style: { fontWeight: 700, fontSize: 14 } },
`📇 ${card.title}`),
h("div", { key: "m", style: S.meta },
`${card.id} | ${card.kind} | ${CARD_STATUS_ICON[card.status] ?? card.status}${card.filled_by ? " | 填充:" + card.filled_by : ""}`),
cardFileEntry,
childLink,
card.summary ? h("div", { key: "s", style: S.drawerSection },
h("div", { style: S.drawerH }, "摘要"), card.summary) : null,
h("div", { key: "body", style: S.drawerSection },
h("div", { style: S.drawerH }, "全文"),
h("div", { style: { whiteSpace: "pre-wrap" } }, card.content?.trim() || "(尚未采集内容)")),
collectPanel,
// g-128:卡片删除按钮(二次确认 + 输入卡片 id 防误删)
h("div", { key: "del", style: { ...S.drawerSection, borderTop: "1px solid rgba(128,128,128,.25)", paddingTop: 8 } },
deleteConfirm
? h("div", { style: { display: "flex", flexDirection: "column", gap: 6 } },
h("div", { style: { ...S.meta, color: "#d66", fontSize: 12 } },
`⚠️ 确认删除卡片「${card.title}」?请输入卡片 id 确认:`),
h("div", { style: { ...S.meta, fontSize: 11, opacity: 0.7 } },
`id:${card.id}`),
h("input", {
style: { ...S.promptInput, fontSize: 12 },
value: deleteIdInput,
placeholder: "输入卡片 id 确认删除…",
onChange: (e) => setDeleteIdInput(e.target.value),
}),
h("div", { style: { display: "flex", gap: 6 } },
h("button", {
style: { ...S.btn, fontSize: 11, padding: "2px 8px", background: deleteIdInput.trim() === card.id ? "rgba(214,102,102,.3)" : undefined },
className: "dg-btn",
disabled: deleteIdInput.trim() !== card.id,
onClick: async () => {
try {
const r = await fetch(graphUrl("/api/dsh-graph/delete-card"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: props.goalId, card: props.cardId }),
});
const data = await r.json();
if (data.ok) {
setDeleteNote("✅ 卡片已删除");
showToast("✅ 卡片已删除");
setDeleteConfirm(false);
setDeleteIdInput("");
// 关闭抽屉并刷新
props.onClose?.();
if (props.onDeleted) props.onDeleted();
} else {
setDeleteNote("⚠️ 删除失败:" + (data.error || "未知错误"));
}
} catch (e) {
setDeleteNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
},
}, "🗑 确认删除"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "2px 8px" },
className: "dg-btn",
onClick: () => { setDeleteConfirm(false); setDeleteIdInput(""); setDeleteNote(null); },
}, "取消"))
)
: h("div", { style: { display: "flex", gap: 6, alignItems: "center" } },
h("button", {
style: { ...S.btn, fontSize: 11, padding: "2px 8px", background: "rgba(214,102,102,.2)" },
className: "dg-btn",
title: "删除此卡片(需输入卡片 id 确认)",
onClick: () => { setDeleteConfirm(true); setDeleteIdInput(""); setDeleteNote(null); },
}, "🗑 删除卡片")),
deleteNote ? h("div", { style: { ...S.meta, marginTop: 4, fontSize: 11 } }, deleteNote) : null),
// g-107:卡片会话内嵌——实时状态/模型/直达指令/最近记录
// g-109 判据反馈:收集子代理出错时在实时会话控件内换 provider/model 重新收集
card.child_id
? h(SessionPanel, { key: "live", parentId: card.parent_session_id, childId: card.child_id, collapsible: true,
goalId: props.goalId, relaunchKind: "collect",
relaunchCardId: props.cardId, relaunchPrompt: promptText || autoPrompt,
relaunchRoute, onRelaunched: setRelaunchRoute })
: null,
];
}
}
return h(
"div",
null,
h("div", { style: { ...S.overlay, background: "rgba(0,0,0,.35)" }, onClick: props.onClose }),
h("div", { style: S.drawer, onClick: (e) => e.stopPropagation() },
h("span", { style: S.close, onClick: props.onClose }, "✕"),
inner),
);
const [open, setOpen] = React.useState(false);
const { summary } = props;
if (!summary) return null;
if (summary.length <= 40) {
return h("div", { style: { opacity: 0.75, marginTop: 1 } }, summary);
}
return h("div", {
style: { opacity: 0.75, marginTop: 1, cursor: "pointer" },
className: open ? "dg-summary-open" : "dg-summary-clamp",
title: open ? "点击收起摘要" : "点击展开摘要全文",
onClick: (e) => { e.stopPropagation(); setOpen(!open); },
}, summary);
}
// 目标卡:只保留关键信息(标题/状态/状态行/徽标/依赖),子卡片扼要列出、点击开抽屉
// 依赖徽章状态化(发现#23):已交付依赖显示「依赖满足」,仅未交付依赖显示「等待」并触发琥珀边框
// 被复用徽章(g-a92e1406):reused_by 由 boardProjection 派生(attempt.reused 事件 + 绑定记录双源),
// 客户端直接消费 g.reused_by,不再用数组顺序猜测旧/新绑定。
// g-125:所有卡片统一用标题左侧小三角展开/收起;delivered/blocked 默认折叠精简
//(折叠态只留标题+状态行,隐藏依赖/livestrip/执行按钮/上下文卡片),可展开查看完整。
// expanded 默认值由 KanbanView 决定(delivered/blocked 默认 false,其余默认 true),
// 用户手动切换后记录到 expandedGoals;Card 保持纯函数(无 hooks)。
// g-77647351:drag 参数——可选拖放对象 {active, marker, start, hover, drop, end}
function Card(g, onOpen, onOpenCard, activeGoal, activeCard, goalStatus, expanded, onToggleExpand, drag) {
const blocked = g.status === "blocked";
const collapsed = !expanded;
const deps = g.depends_on ?? [];
const pendingDeps = deps.filter((d) => goalStatus?.[d] !== "delivered");
const metDeps = deps.filter((d) => goalStatus?.[d] === "delivered");
const hasDep = pendingDeps.length > 0;
const style = {
...S.goalCard,
...(hasDep ? S.depCard : {}),
...(blocked ? S.blockedCard : {}),
};
const badges = [];
if (g.reviewer === "human") badges.push("👤人审");
if (g.reviewer === "ai") badges.push("🤖AI审");
if (g.pk_lanes > 1) badges.push("PK×" + g.pk_lanes);
if (g.archived) badges.push("📦已归档");
const reusedBy = g.reused_by ?? null;
// g-125:标题左侧小三角(▸ 折叠 / ▾ 展开),所有卡片统一;点击卡片其余区域打开详情
// fb3:独立 .dg-chevron 样式——暗底纹、窄宽度(不用 S.btn/dg-btn,避免播放按钮观感)
// fb4:按钮与标题 inline 同行(非 flex 列)——标题换行时第二行从行首开始,不被按钮占去宽度
const chevron = h("button", {
style: { marginRight: 4, verticalAlign: "middle", display: "inline-block" },
className: "dg-chevron",
title: collapsed ? "展开查看依赖/实时会话/上下文卡片等完整信息" : "收起为精简视图",
onClick: (e) => { e.stopPropagation(); onToggleExpand(g.id); },
}, collapsed ? "▸" : "▾");
const titleRow = h("div", { style: { lineHeight: 1.5 } },
chevron,
h("span", { style: { ...S.title, display: "inline", verticalAlign: "middle" } }, `🎯 ${g.title}`));
// g-77647351:拖放 class 合并
const dragClass = [
"dg-card",
activeGoal ? " dg-card-active" : "",
drag?.active ? " dg-dragging" : "",
drag?.marker === "before" ? " dg-drop-before" : "",
drag?.marker === "after" ? " dg-drop-after" : "",
].filter(Boolean).join(" ");
// g-77647351:拖放事件 props
const dragProps = drag ? {
draggable: true,
onDragStart: (e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", g.id);
drag.start();
},
onDragEnd: () => {
if (drag?.over) drag.drop(drag.over);
else drag.end();
},
} : {};
const dropProps = drag ? {
onDragOver: (e) => {
// 修复(负责人 2026-08-22):拖过任意卡片(非源)都应响应并显示 marker 占位——
// 原守卫 !drag.active 只对源卡片 true,导致目标位置不为空时无 drop 指示。
if (drag.goalId === g.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
drag.hover(rowHalf(e));
},
onDrop: (e) => {
if (drag.goalId === g.id) return;
e.preventDefault();
drag.drop(rowHalf(e));
},
} : {};
if (collapsed) {
// g-125 折叠态:仅核心——标题(≤2 行)+ 状态一行;不显示状态摘要、依赖、livestrip、执行按钮、上下文卡片
return h(
"div",
{ key: g.id, style, className: dragClass,
title: "点击打开详情", onClick: () => onOpen(g.id), ...dragProps, ...dropProps },
titleRow,
h("div", { style: S.meta },
`${g.id} | ${STATUS_LABEL[g.status] ?? g.status}${badges.length ? " | " + badges.join(" ") : ""}`),
);
}
return h(
"div",
{ key: g.id, style, className: dragClass,
title: "点击打开详情", onClick: () => onOpen(g.id), ...dragProps, ...dropProps },
titleRow,
h("div", { style: S.meta },
`${g.id} | ${STATUS_LABEL[g.status] ?? g.status}${badges.length ? " | " + badges.join(" ") : ""}`,
sessionLinkBtn(g.attempt_parent_session_id, g.attempt_child_id, "↗ 转到对话")),
hasDep
? h("div", { style: { ...S.meta, color: "#e0a53a" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`)
: null,
metDeps.length
? h("div", { style: { ...S.meta, color: "#3aa675" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`)
: null,
blocked && g.blocked_reason
? h("div", { style: { ...S.statusLine, color: "#d66" } }, "⛔ " + g.blocked_reason)
: null,
// g-a92e1406:执行会话内嵌实时条——status_line 摘要并入状态小窗
//(运行中 ⏳ / 空闲刚执行完 ✅);无执行会话时退化为独立状态行(带动画)
g.attempt_child_id
? h("div", { key: "live" },
h(LiveStrip, { parentId: g.attempt_parent_session_id, childId: g.attempt_child_id,
statusLine: g.status_line }))
: g.status_line
? h(StatusLine, { text: g.status_line, blocked: g.status === "blocked", running: g.status === "in_progress" })
: null,
reusedBy ? h(ReusedBadge, { childId: g.attempt_child_id, reusedBy }) : null,
(g.cards ?? []).map((c) =>
h("div", {
key: c.id,
style: { ...S.subCard, cursor: "pointer" },
className: "dg-sub" + (activeCard === c.id ? " dg-sub-active" : ""),
title: "点击打开上下文抽屉",
onClick: (e) => { e.stopPropagation(); onOpenCard(g.id, c.id); },
},
h("div", { style: { display: "flex", alignItems: "center", gap: 4 } },
h("span", { style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
`📇 ${CARD_STATUS_ICON[c.status] ?? c.status} | ${c.title}`),
sessionLinkBtn(c.parent_session_id, c.child_id, "↗")),
h(CardSummary, { summary: c.summary }),
c.child_id && c.status !== "filled" && c.status !== "reviewed"
? h("div", { onClick: (e) => e.stopPropagation() },
h(LiveStrip, { parentId: c.parent_session_id, childId: c.child_id }))
: null)),
);
}
// g-a92e1406:状态摘要行——运行中带流动背景+图标动画,阻塞行静态
function StatusLine(props) {
const { text, blocked, running } = props;
if (!text) return null;
const STAGES = [
{ key: "describe", label: "描述", statuses: ["draft", "planning"] },
{ key: "collect", label: "收集", statuses: ["collecting", "ready"] },
{ key: "execute", label: "执行", statuses: ["in_progress"] },
{ key: "confirm", label: "确认", statuses: ["review"] },
{ key: "deliver", label: "交付", statuses: ["delivered"] },
{ key: "blocked", label: "阻塞", statuses: ["blocked"] },
];
const STATUS_LABEL = {
draft: "草稿", planning: "规划中", collecting: "收集中", ready: "就绪",
in_progress: "执行中", review: "评审中", delivered: "已交付", blocked: "阻塞",
};
const EVENT_LABEL = {
"goal.created": "创建目标", "goal.planned": "完成规划", "criteria.confirmed": "确认判据",
"goal.transition": null, "attempt.started": "派发执行", "attempt.status_reported": null,
"completion.claimed": "声明完成", "review.passed": "评审通过", "review.failed": "评审未通过",
"goal.moved": "排期移动", "card.created": "创建卡片", "card.filled": "填充卡片",
"card.reviewed": "复核卡片", "evidence.added": "登记证据", "memory.promoted": "沉淀记忆",
"version.created": "创建版本", "version.released": "发布版本",
"version.status_changed": "版本状态变更", "version.scope_changed": "调整版本范围", "version.integration_decided": "集成测试决策",
"goal.deleted": "删除目标", "card.deleted": "删除卡片", "attempt.bound": "绑定子代理",
"goal.renamed": "重命名目标",
"goal.directive_set": "设置最近指令", "goal.comment_added": "添加评论",
"attempt.handoff.confirmed": "确认返工 handoff", "attempt.handoff.superseded": "覆盖旧 handoff",
};
// 近期动态只保留对人有用的事件:泳道切换、修订与人工补充、判据/评审/交付关键节点
// g-a92e1406:补 attempt.status_reported(状态汇报履历)
const MEANINGFUL = new Set([
"goal.transition", "goal.amended", "scope.note", "criteria.confirmed",
"completion.claimed", "review.passed", "review.failed", "attempt.started",
"goal.moved", "goal.created", "attempt.status_reported", "goal.renamed",
"goal.directive_set", "goal.comment_added",
"attempt.handoff.confirmed", "attempt.handoff.superseded",
]);
// 拆出事件三要素(时间/事件/执行者),供表格列渲染与 humanEvent 复用
function eventParts(e) {
const d = e.details ?? {};
let what = EVENT_LABEL[e.event];
if (what === null || what === undefined) {
if (e.event === "goal.transition") what = `状态流转:${STATUS_LABEL[d.from] ?? d.from} → ${STATUS_LABEL[d.to] ?? d.to}`;
else if (e.event === "attempt.status_reported") what = `汇报:${d.status ?? ""}`;
else if (e.event === "goal.amended") what = `修订:${d.note ?? ""}`;
else if (e.event === "goal.renamed") what = `重命名:${d.old_title ?? ""} → ${d.new_title ?? ""}`;
else if (e.event === "scope.note") what = `补充:${d.note ?? ""}`;
else if (e.event === "goal.directive_set") what = `设置指令:${(d.directive ?? "").slice(0, 80)}${(d.directive ?? "").length > 80 ? "…" : ""}`;
else if (e.event === "goal.comment_added") what = `评论:${(d.text ?? "").slice(0, 60)}${(d.text ?? "").length > 60 ? "…" : ""}`;
else if (e.event === "attempt.handoff.confirmed") what = `确认 handoff:${d.handoff ?? ""}`;
else if (e.event === "attempt.handoff.superseded") what = `覆盖 handoff:${d.old_handoff ?? ""} → ${d.new_handoff ?? ""}`;
else what = e.event;
}
const who = String(e.actor ?? "")
.replace(/^human:/, "").replace(/^supervisor:.*/, "主管 Agent")
.replace(/^agent:session-.*/, "Agent(另一会话)").replace(/^agent:/, "Agent:");
let when = "";
try {
const dt = new Date(e.ts);
when = `${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
} catch { when = String(e.ts ?? "").slice(5, 16); }
return { when, what, who };
}
function humanEvent(e) {
const { when, what, who } = eventParts(e);
return `${when} ${what}(${who})`;
}
const HOVER_CSS = `
.dg-card { transition: box-shadow .12s ease, transform .12s ease, border-color .12s ease; }
.dg-card:hover { box-shadow: 0 0 0 2px rgba(76,141,255,.55); transform: translateY(-1px); }
.dg-card:active { transform: translateY(0); box-shadow: 0 0 0 2px rgba(76,141,255,.8); }
.dg-sub { transition: background .12s ease; }
.dg-sub:hover { background: rgba(58,166,117,.22); }
.dg-collapsed:hover { background: rgba(128,128,128,.14); }
.dg-btn { transition: filter .12s ease; }
.dg-btn:hover { filter: brightness(1.25); }
/* g-125 fb3:三角展开/收起按钮——暗底纹、窄宽度,不占整列、不像播放按钮 */
.dg-chevron {
background: rgba(128,128,128,.18);
border: none;
border-radius: 4px;
padding: 0 3px;
min-width: 16px;
width: auto;
flex: 0 0 auto;
color: inherit;
cursor: pointer;
line-height: 1.6;
font-size: 11px;
opacity: .8;
transition: background .12s ease, opacity .12s ease;
}
.dg-chevron:hover { background: rgba(128,128,128,.32); opacity: 1; }
.dg-card-active { box-shadow: 0 0 0 2px rgba(76,141,255,.85) !important; background: rgba(76,141,255,.12) !important; }
.dg-sub-active { background: rgba(58,166,117,.30) !important; box-shadow: 0 0 0 1px #3aa675 !important; }
.dg-supervisor { position: sticky; top: 0; z-index: 50; backdrop-filter: blur(6px); }
/* g-a92e1406:运行中状态摘要流动背景 + 图标动画 */
@keyframes dg-flow-bg {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes dg-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.25); }
}
@keyframes dg-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.dg-running-flow {
background: linear-gradient(90deg, rgba(76,141,255,0.30), rgba(58,166,117,0.42), rgba(76,141,255,0.30));
background-size: 200% 100%;
animation: dg-flow-bg 2.5s ease infinite;
border-radius: 4px;
padding: 2px 6px;
box-shadow: inset 0 0 0 1px rgba(76,141,255,.45);
}
.dg-running-flow .dg-icon-pulse { animation: dg-pulse 1.2s ease-in-out infinite; display: inline-block; }
.dg-running-flow .dg-icon-spin { animation: dg-spin 1.5s linear infinite; display: inline-block; }
/* 阻塞行保持静态,无动画类 */
/* g-125:上下文摘要默认折叠 2 行(截断+省略),展开全文 */
.dg-summary-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.dg-summary-clamp:hover { text-decoration: underline; }
/* g-137:backlog 行平铺展示样式 */
.dg-backlog-lane {
background: rgba(0,0,0,.15);
border-radius: 6px;
padding: 4px;
}
.dg-backlog-flat {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
min-height: 40px;
align-content: flex-start;
}
.dg-backlog-flat .dg-card {
flex: 0 0 220px;
width: 220px;
box-sizing: border-box;
}
.dg-backlog-flat .dg-cell-drop-active {
background: rgba(76,141,255,.08);
}
/* g-77647351:拖放视觉反馈 */
.dg-dragging { opacity: 0.45; transform: scale(0.97); }
.dg-drop-before { border-top: 2px solid #4c8dff !important; }
.dg-drop-after { border-bottom: 2px solid #4c8dff !important; }
.dg-cell-drop-active { background: rgba(76,141,255,.10); border-radius: 4px; }
.dg-drag-ghost { position: fixed; pointer-events: none; z-index: 99999; opacity: 0.85;
max-width: 260px; padding: 6px 10px; border-radius: 6px;
background: rgba(30,31,36,.92); border: 1px solid rgba(76,141,255,.55);
box-shadow: 0 4px 16px rgba(0,0,0,.35); font-size: 12px; font-weight: 600;
color: #e6e6e6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
`;
const S = {
function BackwardReasonPrompt(props) {
const { goalId, toStatus, hasChild, childId, parentId, onConfirm, onCancel } = props;
const [reason, setReason] = React.useState("");
const [sending, setSending] = React.useState(false);
const [sent, setSent] = React.useState(false);
// 如果有子代理,通过 session.prompt 发送理由
const { session } = useBoundSession(parentId, childId);
const sendReason = async () => {
if (!reason.trim()) { onConfirm(""); return; }
if (hasChild && session?.prompt) {
setSending(true);
try {
await session.prompt(
[{ type: "text", text: `【${goalId} 回退理由】${reason.trim()}` }], "queue");
setSent(true);
setTimeout(() => onConfirm(reason.trim()), 800);
} catch {
onConfirm(reason.trim());
}
setSending(false);
} else {
onConfirm(reason.trim());
}
};
return h("div", { style: S.overlay, onClick: onCancel },
h("div", { style: { ...S.modal, maxWidth: 480 }, onClick: (e) => e.stopPropagation() },
h("span", { style: S.close, onClick: onCancel }, "✕"),
h("div", { style: { fontWeight: 700, fontSize: 14, marginBottom: 8 } },
`⬅️ 回退到「${STATUS_LABEL[toStatus] ?? toStatus}」`),
h("div", { style: { ...S.meta, marginBottom: 8 } },
`目标 ${goalId} 将从当前状态回退到「${STATUS_LABEL[toStatus] ?? toStatus}」。`,
h("br"),
hasChild
? "理由将作为消息发送给执行子代理。"
: "理由将作为补充信息记录(无执行子代理时供主管参考)。"),
h("textarea", {
style: { ...S.promptInput, width: "100%", minHeight: 80, resize: "vertical", marginTop: 4 },
value: reason,
placeholder: "请输入回退理由(可选)…",
onChange: (e) => setReason(e.target.value),
}),
h("div", { style: { display: "flex", gap: 8, marginTop: 8 } },
h("button", {
style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn",
disabled: sending, onClick: sendReason,
}, sending ? "发送中…" : (sent ? "✅ 已发送" : "确认回退")),
h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 12 }, className: "dg-btn",
onClick: onCancel,
}, "取消")),
),
);
}
// g-77647351:进执行列确认弹窗
// 无子代理 → force transition + start-execution 派新子代理
// 有子代理 → force transition + 通过 session.prompt 给旧子代理排队重新执行(不派新)
function InProgressPrompt(props) {
const { goalId, goalData, supervisorSession, onConfirm, onCancel } = props;
const [loading, setLoading] = React.useState(false);
const [note, setNote] = React.useState(null);
const hasChild = !!(goalData?.attempt_child_id);
const hasCriteria = !!(goalData?.criteria_count);
const oldChildId = goalData?.attempt_child_id ?? null;
const oldParentId = goalData?.attempt_parent_session_id ?? null;
// 有子代理时用 session.prompt 排队重新执行,无子代理时派新
const { session: oldSession } = useBoundSession(oldParentId, oldChildId);
const startExec = async () => {
if (!supervisorSession) {
setNote("⚠️ 该 workspace 未配置 supervisor.session(project.yaml)。请先在此 workspace 运行 graph_claim_supervisor() 完成主管会话接管,再执行。");
return;
}
setLoading(true);
try {
// Step 1: force transition 到 in_progress(人工拖动视为授权)
setNote("迁移中…");
const tr = await fetch(graphUrl("/api/dsh-graph/transition"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, to: "in_progress", force: true }),
});
const trData = await tr.json();
if (!trData.ok) {
setNote("⚠️ 状态迁移失败:" + (trData.error || "未知错误"));
setLoading(false);
return;
}
if (hasChild && oldSession?.prompt) {
// 有子代理 → 排队发"重新执行"消息,不派新子代理
setNote("发送重新执行指令…");
try {
const res = await oldSession.prompt(
[{ type: "text", text: `【重新执行】用户从看板拖放触发重新执行目标 ${goalId}。请从头开始执行目标描述和质量判据中的任务。` }],
"queue",
);
if (res?.ok) {
setNote("✅ 已向子代理排队发送重新执行指令");
showToast("✅ 已向子代理发送重新执行指令");
} else {
setNote("⚠️ 发送失败:" + (res?.error?.message ?? "未知错误") + "。请打开子代理会话手动操作。");
}
} catch (e) {
setNote("⚠️ 发送失败:" + String(e?.message ?? e) + "。请打开子代理会话手动操作。");
}
setTimeout(() => { onConfirm(); }, 1500);
} else {
// 无子代理 → 派发新执行子代理
setNote("派发子代理…");
const r = await fetch(graphUrl("/api/dsh-graph/start-execution"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId }),
});
const data = await r.json();
if (data.ok) {
if (data.child_id) {
setNote("✅ 已派发执行子代理,id:" + data.child_id);
showToast("✅ 已派发执行子代理");
} else if (data.child_error) {
setNote("⚠️ 子代理启动失败:" + data.child_error);
} else {
setNote("⚠️ 子代理未启动(无 child_id)");
}
setTimeout(() => { onConfirm(); }, 1200);
} else {
setNote("⚠️ 执行失败:" + (data.error || "未知错误"));
}
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
return h("div", { style: S.overlay, onClick: onCancel },
h("div", { style: { ...S.modal, maxWidth: 480 }, onClick: (e) => e.stopPropagation() },
h("span", { style: S.close, onClick: onCancel }, "✕"),
h("div", { style: { fontWeight: 700, fontSize: 14, marginBottom: 8 } },
`🚀 执行「${goalData?.title ?? goalId}」`),
h("div", { style: { ...S.meta, marginBottom: 8 } },
hasChild
? "该目标已有执行子代理。将向其发送重新执行指令(排队),不另起新子代理。"
: "将为目标创建执行子代理,状态迁移到「执行中」。"),
// 有子代理时:提供链接让用户自己打开会话管理
hasChild && oldChildId
? h("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8 } },
h("span", { style: { fontSize: 12 } }, "🔗 子代理:"),
h("button", {
style: { ...S.btn, fontSize: 12, padding: "2px 8px" }, className: "dg-btn",
onClick: (e) => {
e.stopPropagation();
if (oldParentId) openChildSession(oldParentId, oldChildId);
},
}, oldChildId.slice(0, 8) + "… ↗"))
: null,
!hasCriteria
? h("div", { style: { ...S.meta, color: "#e0a53a", marginBottom: 4 } },
"⚠️ 质量判据尚未登记——将以授权模式强制迁移到执行列。")
: null,
h("div", { style: { display: "flex", gap: 8, marginTop: 4 } },
h("button", {
style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn",
disabled: loading, onClick: startExec,
}, loading ? "处理中…" : (hasChild ? "🔄 重新执行" : "🚀 确认执行")),
h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 12 }, className: "dg-btn",
onClick: onCancel,
}, "取消")),
note ? h("div", { style: { ...S.meta, marginTop: 6 } }, note) : null,
),
);
}
// g-77647351:交付确认弹窗——告知主管需做代码合并等交付工作,提供跳转主管会话按钮
function DeliverPrompt(props) {
const { goalId, goalTitle, supervisorSession, onConfirm, onCancel } = props;
const promptText = `【交付通知】目标「${goalTitle ?? goalId}」(${goalId})即将标记为已交付。请进行最终复核:代码合并、文档更新等交付工作。`;
const jumpToSupervisor = async () => {
try {
const copied = await copyText(promptText);
const rt = sessionsRt ?? appCtx?.get?.("sessions");
if (rt && supervisorSession) {
rt.open?.(supervisorSession);
activateChatTab();
}
if (copied) {
showToast("✅ 预填内容已复制,到主管对话窗 Ctrl+V 直接粘贴发送");
}
} catch { /* 静默 */ }
};
return h("div", { style: S.overlay, onClick: onCancel },
h("div", { style: { ...S.modal, maxWidth: 520 }, onClick: (e) => e.stopPropagation() },
h("span", { style: S.close, onClick: onCancel }, "✕"),
h("div", { style: { fontWeight: 700, fontSize: 14, marginBottom: 8 } },
`📦 交付「${goalTitle ?? goalId}」`),
h("div", { style: { ...S.meta, marginBottom: 8, lineHeight: 1.8 } },
"交付前请确保以下工作已完成:", h("br"),
"• 代码已合并到主分支", h("br"),
"• 相关文档/配置已更新", h("br"),
"• 已通知主管进行最终复核", h("br"),
h("br"),
h("span", { style: { color: "#e0a53a" } },
"⚠️ 标记为「已交付」后需主管评审通过才能正式完成。")),
h("div", { style: { display: "flex", gap: 8, marginTop: 4, flexWrap: "wrap" } },
supervisorSession
? h("button", {
style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn",
onClick: jumpToSupervisor,
}, "↗ 告知主管")
: null,
h("button", {
style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn",
onClick: () => onConfirm(),
}, "📦 确认交付"),
h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 12 }, className: "dg-btn",
onClick: onCancel,
}, "取消")),
),
);
}
function KanbanView(props) {
const [state, setState] = React.useState({ loading: true });
}
// 质量判据 checklist(确认阶段):每条一个勾选框(localStorage 按目标持久化,仅前端评审草稿)
// + 「💬 反馈」按钮——展开输入框,经 session.prompt 排队送达该目标的执行会话(复用 g-107 通路)。
function CriteriaChecklist(props) {
const items = String(props.crit ?? "").split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("<!--"));
const storeKey = "dsh-graph.crit." + props.goalId;
const readChecked = () => {
try { return JSON.parse(localStorage.getItem(storeKey) ?? "[]"); } catch { return []; }
};
const [checked, setChecked] = React.useState(readChecked);
const [fbIdx, setFbIdx] = React.useState(-1);
const [fbText, setFbText] = React.useState("");
const [fbNote, setFbNote] = React.useState(null);
const { session } = useBoundSession(props.att?.parent_session_id ?? null, props.att?.child_id ?? null);
if (!items.length) return null;
const toggle = (line) => {
const next = checked.includes(line) ? checked.filter((t) => t !== line) : [...checked, line];
setChecked(next);
try { localStorage.setItem(storeKey, JSON.stringify(next)); } catch {}
};
const sendFb = async (criterion) => {
const t = fbText.trim();
if (!t) return;
if (!session?.prompt) { setFbNote("⚠️ 执行会话未接入,反馈无法送达"); return; }
try {
const res = await session.prompt(
[{ type: "text", text: `【${props.goalId} 判据反馈】${criterion}\n${t}` }], "queue");
if (res?.ok) {
setFbNote("✅ 反馈已排队送达执行会话");
setFbText("");
setFbIdx(-1);
// g-109:判据反馈提交后自动关闭弹窗
if (props.onClose) props.onClose();
}
else setFbNote("⚠️ 反馈发送失败:" + (res?.error?.message ?? "未知错误"));
} catch (e) { setFbNote("⚠️ 反馈发送失败:" + String(e?.message ?? e)); }
};
return h("div", null,
items.map((line, i) => {
const done = checked.includes(line);
const label = line.replace(/^\d+[.、)]\s*/, "");
return h("div", { key: i, style: { marginBottom: 3 } },
h("div", { style: { display: "flex", alignItems: "flex-start", gap: 6 } },
h("input", { type: "checkbox", checked: done, onChange: () => toggle(line),
style: { flexShrink: 0, cursor: "pointer", marginTop: 2 } }),
h("span", { style: { flex: 1, minWidth: 0, opacity: done ? 0.55 : 1,
textDecoration: done ? "line-through" : "none" } }, label),
h("button", { style: { ...S.btn, flexShrink: 0 }, className: "dg-btn",
title: "针对此判据向执行会话反馈",
onClick: () => { setFbIdx(fbIdx === i ? -1 : i); setFbNote(null); } },
"💬 反馈")),
fbIdx === i
? h("div", { style: { display: "flex", gap: 4, marginTop: 3, marginLeft: 22 } },
h("input", { style: S.promptInput, value: fbText, placeholder: "反馈内容…",
onChange: (e) => setFbText(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") sendFb(line); } }),
h("button", { style: S.btn, className: "dg-btn", onClick: () => sendFb(line) }, "发送"))
: null);
}),
fbNote ? h("div", { style: { ...S.meta, marginTop: 3 } }, fbNote) : null);
}
// 轻量 toast:底部居中浮层,约 2.5s 自动消失(零依赖,不引入 DSH 内部组件)
function showToast(text) {
const host = document.createElement("div");
host.style.cssText =
"position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:99999;" +
"background:rgba(30,30,30,.94);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;" +
"box-shadow:0 4px 16px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity .18s ease;max-width:80vw;";
host.textContent = text;
document.body.appendChild(host);
requestAnimationFrame(() => { host.style.opacity = "1"; });
setTimeout(() => {
host.style.opacity = "0";
setTimeout(() => { if (host.parentNode) document.body.removeChild(host); }, 200);
}, 2500);
}
// 复制文本到剪贴板:优先 Clipboard API(需安全上下文+用户手势),失败回退 execCommand(textarea 选中法)
async function copyText(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* 回退 */ }
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
// g-109:目标描述区执行/反馈交互组件(执行按钮直接创建子代理;接受默认经主管 Agent 复核,
// 无异议生效,有异议显示在按钮处并转「强制接受」,可选理由记 goal.amended 事件供学习)
function AcceptFeedback(props) {
const { goalId, status, events, supervisorSession, onRefresh } = props;
const [mode, setMode] = React.useState("idle"); // idle | feedback
const [fbText, setFbText] = React.useState("");
const [note, setNote] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [forceMode, setForceMode] = React.useState(false); // 异议后是否展开强制接受理由输入
const [forceReason, setForceReason] = React.useState("");
// 反馈预填模板(复制与显示共用,保证一致)
const prefillText = fbText.trim() ? `【${goalId} 反馈】\n${fbText.trim()}` : "";
// 接受复核状态(与 core readAcceptStatus 同语义的事件流推断):
// none(未请求)/ pending(已请求待主管裁决)/ objection(主管异议)/ resolved(已生效)
const evs = events ?? [];
let lastReq = -1, lastObj = -1, lastRes = -1;
evs.forEach((e, i) => {
if (e.event === "review.requested") lastReq = i;
if (e.event === "review.objected") lastObj = i;
if (["description.confirmed", "criteria.confirmed", "review.passed"].includes(e.event)) lastRes = i;
});
let acceptState = "none";
if (lastReq >= 0) {
if (lastObj > lastReq) acceptState = "objection";
else if (lastRes > lastReq) acceptState = "resolved";
else acceptState = "pending";
}
const objectionText = acceptState === "objection" ? evs[lastObj]?.details?.objection : null;
// 接受:默认经主管 Agent 复核(review.requested → 主管裁决)
const doAccept = async () => {
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/accept"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId }),
});
const data = await r.json();
if (data.pending) setNote("✅ 已请求主管复核接受,等待主管裁决(无异议即生效)");
else if (data.ok) setNote("✅ 已接受");
else setNote("⚠️ 接受失败:" + (data.error || "未知错误"));
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
// 强制接受:跳过主管复核,可选理由记入 goal.amended 事件
const doForceAccept = async () => {
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/accept"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, force: true, reason: forceReason.trim() || undefined }),
});
const data = await r.json();
if (data.ok) setNote(forceReason.trim() ? "✅ 已强制接受(理由已记入事件)" : "✅ 已强制接受");
else setNote("⚠️ 强制接受失败:" + (data.error || "未知错误"));
setForceMode(false);
setForceReason("");
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const startExecution = async () => {
setLoading(true);
try {
// Step 1: force transition 到 in_progress(人工操作视为授权)
const tr = await fetch(graphUrl("/api/dsh-graph/transition"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, to: "in_progress", force: true }),
});
const trData = await tr.json();
if (!trData.ok) {
setNote("⚠️ 状态迁移失败:" + (trData.error || "未知错误"));
setLoading(false);
return;
}
// Step 2: 派发执行子代理
const r = await fetch(graphUrl("/api/dsh-graph/start-execution"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId }),
});
const data = await r.json();
if (data.ok) {
if (data.child_id) {
setNote("✅ 已派发执行子代理,id:" + data.child_id);
} else if (data.child_error) {
setNote("⚠️ 子代理启动失败:" + data.child_error);
} else {
setNote("⚠️ 子代理未启动(无 child_id)");
}
onRefresh?.(); // g-148:刷新看板(回调由父组件 GoalModal 传入)
} else {
setNote("⚠️ 执行失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const openSupervisorWithFeedback = async () => {
try {
const rt = sessionsRt ?? appCtx?.get?.("sessions");
if (!rt) { setNote("⚠️ 会话服务不可用"); return; }
// 打开主管会话(id 由 board 端点下发 project.yaml supervisor.session,g-108)
if (!supervisorSession) { setNote("⚠️ 未配置主管会话(project.yaml 的 supervisor.session)"); return; }
// 自动复制预填内容(负责人指示),再切到主管对话窗直接粘贴发送
const copied = prefillText ? await copyText(prefillText) : false;
rt.open?.(supervisorSession);
activateChatTab();
if (copied) {
showToast("✅ 预填内容已复制,到主管对话窗 Ctrl+V 直接粘贴发送");
setNote("✅ 预填内容已复制,已切换到主管对话窗,直接粘贴发送");
} else {
setNote("⚠️ 自动复制失败(浏览器限制),请手动复制下方预填内容;已切换到主管对话窗");
}
} catch (e) {
setNote("⚠️ 跳转失败:" + String(e?.message ?? e));
}
};
// 是否有活跃「执行」attempt(已启动但未完成)。
// g-109 定点 bug:「开始收集」也写 attempt.started(executor=agent:collect),
// 若不加区分,只收集过未执行的目标其 🚀 执行/💬 反馈会被误藏。
// 只认非 collect 的 attempt:凡非收集类(agent:collect)的 attempt 都视为活跃执行。
const hasActiveAttempt = (events ?? []).some(
(e) => e.event === "attempt.started" && e.details?.executor !== "agent:collect",
);
// review 及之后阶段、或已有活跃 attempt,不显示执行/反馈按钮
const allowed = ["draft", "planning", "collecting", "ready"];
if (!allowed.includes(status) || hasActiveAttempt) return null;
return h("div", { style: { marginTop: 8, display: "flex", flexDirection: "column", gap: 6 } },
h("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
acceptState === "none"
? h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn dg-accept",
disabled: loading, onClick: doAccept,
}, "✅ 接受")
: acceptState === "pending"
? h("span", { style: { ...S.meta, fontSize: 12 } }, "⏳ 已请求主管复核,等待裁决")
: acceptState === "resolved"
? h("span", { style: { ...S.meta, fontSize: 12, color: "#3aa675" } }, "✅ 已接受生效")
: null,
h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn",
disabled: loading,
onClick: startExecution,
}, "🚀 执行"),
h("button", {
style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn",
disabled: loading,
onClick: () => { setMode(mode === "feedback" ? "idle" : "feedback"); setNote(null); },
}, "💬 反馈")),
// g-109 判据:主管有异议 → 显示在按钮处,可转「强制接受」(可选理由记事件供学习)
acceptState === "objection"
? h("div", { key: "obj", style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 2 } },
h("div", { style: { ...S.meta, color: "#e0a53a" } },
"⚠️ 主管异议:" + (objectionText ?? "(无内容)")),
forceMode
? [
h("input", {
style: { ...S.promptInput, flex: 1 },
value: forceReason, placeholder: "强制接受理由(可选,将记入事件)…",
onChange: (e) => setForceReason(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") doForceAccept(); },
}),
h("div", { style: { display: "flex", gap: 6 } },
h("button", {
style: { ...S.btn, fontSize: 12 }, className: "dg-btn dg-accept",
disabled: loading, onClick: doForceAccept,
}, "确认强制接受"),
h("button", {
style: { ...S.btn, fontSize: 12 }, className: "dg-btn",
disabled: loading, onClick: () => { setForceMode(false); setForceReason(""); },
}, "取消")),
]
: h("button", {
style: { ...S.btn, fontSize: 12, alignSelf: "flex-start" }, className: "dg-btn dg-accept",
onClick: () => setForceMode(true),
}, "强制接受(跳过复核)"),
)
: null,
mode === "feedback"
? h("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 2 } },
h("input", {
style: { ...S.promptInput, flex: 1 },
value: fbText, placeholder: "输入反馈内容…",
onChange: (e) => setFbText(e.target.value),
}),
h("button", {
style: { ...S.btn, fontSize: 11, alignSelf: "flex-start" }, className: "dg-btn",
onClick: openSupervisorWithFeedback,
}, "→ 去主管对话窗发送"),
fbText.trim()
? h("div", { style: { ...S.meta, padding: "4px 6px", background: "rgba(128,128,128,.08)", borderRadius: 4 } },
"预填内容(已自动复制):",
h("pre", { style: { margin: "4px 0 0", whiteSpace: "pre-wrap", fontSize: 11 } },
prefillText))
: null)
: null,
note ? h("div", { style: { ...S.meta, marginTop: 2 } }, note) : null,
);
}
// g-109:新增信息收集任务组件(弹窗内信息收集区)
// g-128:新增信息收集任务组件(弹窗内信息收集区)——支持标题+kind 选择
function AddCardBox(props) {
const { goalId, supervisorSession } = props;
const [mode, setMode] = React.useState("idle"); // idle | naming | chat
const [title, setTitle] = React.useState("");
const [kind, setKind] = React.useState("text"); // g-128:卡片类型可选
const [note, setNote] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const addByName = async () => {
const t = title.trim();
if (!t) return;
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/add-card"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, title: t, kind }),
});
const data = await r.json();
if (data.ok) {
setNote("✅ 已创建任务:" + data.card);
setTitle("");
setKind("text");
setMode("idle");
} else {
setNote("⚠️ 创建失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
// 对话创建:打开主管会话让用户直接输入需求
const openSupervisorChat = () => {
try {
const rt = sessionsRt ?? appCtx?.get?.("sessions");
if (!rt) { setNote("⚠️ 会话服务不可用"); return; }
// 主管会话 id 由 board 端点下发(project.yaml supervisor.session,g-108)
if (!supervisorSession) { setNote("⚠️ 未配置主管会话(project.yaml 的 supervisor.session)"); return; }
rt.open?.(supervisorSession);
activateChatTab();
setNote("✅ 已切换到对话窗,请直接输入收集需求");
} catch (e) {
setNote("⚠️ 跳转失败:" + String(e?.message ?? e));
}
};
// g-128:kind 选项标签
const kindLabels = { text: "📝 文本", file: "📄 文件", image: "🖼 图片", data: "📊 数据" };
return h("div", { style: { marginTop: 8 }, className: "dg-card-add" },
h("div", { style: { display: "flex", gap: 6, alignItems: "center" } },
h("span", { style: { ...S.meta, fontSize: 11 } }, "新增信息收集任务:"),
h("button", { style: S.btn, className: "dg-btn", onClick: () => { setMode("naming"); setNote(null); } }, "📝 一句话任务"),
h("button", { style: S.btn, className: "dg-btn", onClick: () => { setMode("chat"); setNote(null); } }, "💬 通过对话创建")),
mode === "naming"
? h("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 4 } },
h("div", { style: { display: "flex", gap: 4, alignItems: "center" } },
h("input", {
style: { ...S.promptInput, flex: 1 },
value: title, placeholder: "输入任务描述…",
onChange: (e) => setTitle(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") addByName(); },
}),
// g-128:kind 选择下拉框
h("select", {
value: kind,
onChange: (e) => setKind(e.target.value),
style: { fontSize: 12, padding: "4px 6px", cursor: "pointer",
background: "rgba(128,128,128,.10)", color: "inherit",
border: "1px solid rgba(128,128,128,.35)", borderRadius: 4 },
},
...Object.entries(kindLabels).map(([k, v]) =>
h("option", { key: k, value: k }, v))),
h("button", { style: S.btn, className: "dg-btn", onClick: addByName, disabled: loading }, "创建")))
: null,
mode === "chat"
? h("div", { style: { marginTop: 4, padding: "6px 8px", borderRadius: 4, background: "rgba(76,141,255,.08)" } },
h("div", null, "点击按钮切换到主管对话窗,直接描述你想收集的信息,主管 Agent 会帮你创建任务并派发子代理。"),
h("button", {
style: { ...S.btn, marginTop: 6 }, className: "dg-btn",
onClick: openSupervisorChat,
}, "→ 去对话窗输入需求"))
: null,
note ? h("div", { style: { ...S.meta, marginTop: 2 } }, note) : null,
);
}
// 详情 modal:g-a92e1406 改为 tab 结构(详情 / 近期动态)
// g-150:handoff 组件(显示当前有效 handoff + 登记新 handoff)
function HandoffBox(props) {
const { goalId, handoff, attempts, onRefresh } = props;
const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState({ source_attempts: "", failures: "", constraints: "", baseline: "", verification: "" });
const [note, setNote] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const doRecord = async () => {
const src = form.source_attempts.split(",").map((s) => s.trim()).filter(Boolean);
if (!src.length) { setNote("⚠️ 来源 attempt 不能为空"); return; }
if (!form.failures.trim() || !form.constraints.trim() || !form.baseline.trim() || !form.verification.trim()) {
setNote("⚠️ 所有字段必填"); return;
}
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/record-handoff"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, source_attempts: src, ...form }),
});
const data = await r.json();
if (data.ok) {
setNote("✅ handoff 已登记");
setShowForm(false);
setForm({ source_attempts: "", failures: "", constraints: "", baseline: "", verification: "" });
onRefresh?.();
} else {
setNote("⚠️ 登记失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const hasHf = handoff && handoff.failures;
const attOptions = (attempts ?? []).map((a) => a.id);
return h("div", { style: S.modalSection },
h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
h("div", { style: S.modalH },
"🔄 返工 Handoff",
hasHf ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4, fontWeight: 400 } },
`(rev ${handoff.revision},来源:${(handoff.source_attempts ?? []).join(", ")})`) : null),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, className: "dg-btn",
onClick: () => { setShowForm(!showForm); setNote(null); },
}, showForm ? "取消" : hasHf ? "✏️ 更新 Handoff" : "📝 登记 Handoff")),
// 显示当前 handoff
hasHf
? h("div", { style: { marginTop: 6, display: "flex", flexDirection: "column", gap: 4, fontSize: 12 } },
h("div", { style: { opacity: 0.65, fontSize: 11 } },
`确认人:${handoff.confirmed_by} | 时间:${handoff.confirmed_at}`),
h("div", null,
h("strong", null, "已核实失败/风险:"),
h("div", { style: { whiteSpace: "pre-wrap", lineHeight: 1.4, marginTop: 2 } }, handoff.failures)),
h("div", null,
h("strong", null, "返工约束(禁止项):"),
h("div", { style: { whiteSpace: "pre-wrap", lineHeight: 1.4, marginTop: 2 } }, handoff.constraints)),
h("div", null,
h("strong", null, "推荐基线/必须保留项:"),
h("div", { style: { whiteSpace: "pre-wrap", lineHeight: 1.4, marginTop: 2 } }, handoff.baseline)),
h("div", null,
h("strong", null, "验收命令:"),
h("div", { style: { whiteSpace: "pre-wrap", lineHeight: 1.4, marginTop: 2 } }, handoff.verification)))
: h("div", { style: { ...S.meta, fontSize: 12, opacity: 0.6, marginTop: 4 } }, "(无已登记 handoff)"),
// 登记表单
showForm
? h("div", { style: { marginTop: 8, display: "flex", flexDirection: "column", gap: 6, padding: "8px 10px", borderRadius: 6, background: "rgba(128,128,128,.08)" } },
h("div", { style: { fontSize: 11, opacity: 0.7 } }, hasHf ? "更新将覆盖当前 handoff(revision 递增)" : "登记后新 attempt 派发时自动注入"),
h("div", null,
h("label", { style: { fontSize: 11, opacity: 0.8 } }, "来源 attempt(逗号分隔)"),
attOptions.length
? h("div", { style: { display: "flex", gap: 4, flexWrap: "wrap", marginTop: 2 } },
...attOptions.map((a) =>
h("button", {
key: a, style: { ...S.btn, fontSize: 11, padding: "1px 6px",
background: form.source_attempts.includes(a) ? "rgba(76,141,255,.25)" : undefined },
className: "dg-btn",
onClick: () => {
const cur = form.source_attempts.split(",").map((s) => s.trim()).filter(Boolean);
const next = cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a];
setForm({ ...form, source_attempts: next.join(", ") });
},
}, a)))
: null,
h("input", {
style: { ...S.promptInput, fontSize: 12, marginTop: 2 }, value: form.source_attempts,
onChange: (e) => setForm({ ...form, source_attempts: e.target.value }),
placeholder: "att-001, att-002",
})),
...[
["failures", "已核实失败/风险"],
["constraints", "返工约束(禁止项)"],
["baseline", "推荐基线/必须保留项"],
["verification", "验收命令"],
].map(([key, label]) =>
h("div", { key },
h("label", { style: { fontSize: 11, opacity: 0.8 } }, label),
h("textarea", {
style: { ...S.promptInput, minHeight: 36, resize: "vertical", fontFamily: "inherit", fontSize: 12, marginTop: 2, width: "100%", boxSizing: "border-box" },
value: form[key],
onChange: (e) => setForm({ ...form, [key]: e.target.value }),
placeholder: label,
}))),
h("button", {
style: { ...S.btn, fontSize: 12, alignSelf: "flex-start" }, className: "dg-btn",
disabled: loading, onClick: doRecord,
}, "✅ 登记 Handoff"))
: null,
note ? h("div", { style: { ...S.meta, marginTop: 2, fontSize: 11 } }, note) : null);
}
// g-150:最近指令组件(显示 + 编辑)
function DirectiveBox(props) {
const { goalId, directive, onRefresh } = props;
const [editing, setEditing] = React.useState(false);
const [text, setText] = React.useState(directive ?? "");
const [note, setNote] = React.useState(null);
const [loading, setLoading] = React.useState(false);
// 同步外部 directive 变化
React.useEffect(() => { setText(directive ?? ""); }, [directive]);
const doSave = async () => {
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/set-directive"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, directive: text }),
});
const data = await r.json();
if (data.ok) {
setNote("✅ 指令已更新");
setEditing(false);
onRefresh?.();
} else {
setNote("⚠️ 更新失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const doClear = async () => {
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/set-directive"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, directive: "" }),
});
const data = await r.json();
if (data.ok) {
setNote("✅ 指令已清空");
setText("");
setEditing(false);
onRefresh?.();
} else {
setNote("⚠️ 清空失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const hasContent = (directive ?? "").trim().length > 0;
return h("div", { style: S.modalSection },
h("div", { style: S.modalH },
"📌 最近指令",
h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 6, fontWeight: 400 } },
"(下次 attempt 自动注入;小范围修复优先 send_message 续办已有会话)")),
editing
? h("div", { style: { display: "flex", flexDirection: "column", gap: 6 } },
h("textarea", {
style: { ...S.promptInput, minHeight: 60, resize: "vertical", fontFamily: "inherit", fontSize: 12 },
value: text,
onChange: (e) => setText(e.target.value),
placeholder: "输入对下次 attempt 的补充任务、边界和验收要求…",
}),
h("div", { style: { display: "flex", gap: 6 } },
h("button", {
style: { ...S.btn, fontSize: 12 }, className: "dg-btn",
disabled: loading, onClick: doSave,
}, "💾 保存"),
text.trim()
? h("button", {
style: { ...S.btn, fontSize: 12 }, className: "dg-btn",
disabled: loading, onClick: doClear,
}, "🗑 清空")
: null,
h("button", {
style: { ...S.btn, fontSize: 12 }, className: "dg-btn",
disabled: loading, onClick: () => { setEditing(false); setText(directive ?? ""); setNote(null); },
}, "取消")))
: h("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
hasContent
? h("div", { style: { ...S.meta, whiteSpace: "pre-wrap", fontSize: 12, lineHeight: 1.5, padding: "4px 0" } }, directive)
: h("div", { style: { ...S.meta, fontSize: 12, opacity: 0.6 } }, "(无最近指令)"),
h("button", {
style: { ...S.btn, fontSize: 11, alignSelf: "flex-start" }, className: "dg-btn",
onClick: () => { setEditing(true); setText(directive ?? ""); setNote(null); },
}, hasContent ? "✏️ 编辑指令" : "📝 设置指令")),
note ? h("div", { style: { ...S.meta, marginTop: 2, fontSize: 11 } }, note) : null);
}
// g-150:评论组件(历史查看 + 追加)
function CommentsBox(props) {
const { goalId, comments, onRefresh } = props;
const [expanded, setExpanded] = React.useState(false);
const [showAdd, setShowAdd] = React.useState(false);
const [text, setText] = React.useState("");
const [note, setNote] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const doAdd = async () => {
const t = text.trim();
if (!t) return;
setLoading(true);
try {
const r = await fetch(graphUrl("/api/dsh-graph/add-comment"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: goalId, text: t }),
});
const data = await r.json();
if (data.ok) {
setNote("✅ 评论已添加");
setText("");
setShowAdd(false);
onRefresh?.();
} else {
setNote("⚠️ 添加失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setLoading(false);
};
const count = comments.length;
return h("div", { style: S.modalSection },
h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
h("div", { style: S.modalH },
"💬 评论",
count > 0 ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4, fontWeight: 400 } }, `(${count} 条)`) : null),
count > 0
? h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, className: "dg-btn dg-chevron",
onClick: () => setExpanded(!expanded),
}, expanded ? "▲" : "▼")
: null,
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, className: "dg-btn",
onClick: () => { setShowAdd(!showAdd); setNote(null); },
}, showAdd ? "取消" : "➕ 添加评论")),
// 评论历史(可展开/收起)
expanded && count > 0
? h("div", { style: { marginTop: 6, maxHeight: 240, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 } },
comments.map((c, i) =>
h("div", { key: i, style: { padding: "6px 8px", borderRadius: 4, background: "rgba(128,128,128,.08)", fontSize: 12 } },
h("div", { style: { fontSize: 11, opacity: 0.65, marginBottom: 2 } }, `${c.ts} | ${c.author}`),
h("div", { style: { whiteSpace: "pre-wrap", lineHeight: 1.4 } }, c.text))))
: null,
// 添加评论输入
showAdd
? h("div", { style: { marginTop: 6, display: "flex", flexDirection: "column", gap: 4 } },
h("textarea", {
style: { ...S.promptInput, minHeight: 50, resize: "vertical", fontFamily: "inherit", fontSize: 12 },
value: text,
onChange: (e) => setText(e.target.value),
placeholder: "输入评论内容…(可追溯的历史讨论/反馈)",
}),
h("button", {
style: { ...S.btn, fontSize: 12, alignSelf: "flex-start" }, className: "dg-btn",
disabled: loading || !text.trim(), onClick: doAdd,
}, "💬 发表评论"))
: null,
note ? h("div", { style: { ...S.meta, marginTop: 2, fontSize: 11 } }, note) : null);
}
function GoalModal(props) {
const [state, setState] = React.useState({ loading: true });
const [tab, setTab] = React.useState("detail"); // "detail" | "context" | "activity"
const [logSort, setLogSort] = React.useState("desc"); // "desc" | "asc"
const [logFilter, setLogFilter] = React.useState(""); // "" 全部 / 事件名
const [relaunchRoute, setRelaunchRoute] = React.useState(null); // g-109:最近一次重新执行的模型路由(显示兜底)
const [renaming, setRenaming] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const [renameNote, setRenameNote] = React.useState(null);
// g-148:load 提升到组件体,供 AcceptFeedback 通过 onRefresh 回调刷新详情
const aliveRef = React.useRef(true);
const load = React.useCallback(() =>
fetch(graphUrl("/api/dsh-graph/goal", { id: props.id }))
.then((r) => r.json())
.then((data) => aliveRef.current && setState({ loading: false, data }))
.catch((e) => aliveRef.current && setState({ loading: false, error: String(e) })),
[props.id]);
React.useEffect(() => {
aliveRef.current = true;
load();
const t = setInterval(load, 20000);
return () => { aliveRef.current = false; clearInterval(t); };
}, [load]);
const section = (body, name) => {
const m = new RegExp(`## ${name}\\n([\\s\\S]*?)(?=\\n## |$)`).exec(body ?? "");
return m ? m[1].trim() : null;
};
let content;
let headMeta = null; // 标题下 1-2 行:状态/泳道/版本/评审 + 等待/状态摘要
let livePanel = null; // 📡 会话实时:紧随摘要行
if (state.loading) content = "加载详情…";
else if (state.error) content = "详情获取失败:" + state.error;
else if (state.data.error) content = "详情错误:" + state.data.error;
else {
const d = state.data;
const desc = section(d.body, "目标描述");
const crit = section(d.body, "质量判据");
const meta = d.meta ?? {};
const status = String(meta.status ?? "unknown");
const stage = STAGES.find((s) => s.key === stageOf(status));
const deps = (Array.isArray(meta.depends_on) ? meta.depends_on : []).map((x) => String(x?.goal ?? x));
const lastAtt = (d.attempts ?? []).slice(-1)[0];
const statusLine = lastAtt?.status_line ?? null;
const bits = [
props.id,
"状态:" + (STATUS_LABEL[status] ?? status),
stage ? "泳道:" + stage.label : null,
"归属:" + (meta.version ? `版本 ${meta.version}` : "独立/backlog"),
meta.review?.reviewer === "human" ? "👤人审" : meta.review?.reviewer === "ai" ? "🤖AI审" : null,
].filter(Boolean);
const pendingDeps = deps.filter((d) => props.goalStatus?.[d] !== "delivered");
const metDeps = deps.filter((d) => props.goalStatus?.[d] === "delivered");
headMeta = [
h("div", { key: "m1", style: S.meta }, bits.join(" | ")),
pendingDeps.length
? h("div", { key: "m2", style: { ...S.meta, color: "#e0a53a" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`)
: null,
metDeps.length
? h("div", { key: "m2b", style: { ...S.meta, color: "#3aa675" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`)
: null,
status === "blocked" && meta.blocked_reason
? h("div", { key: "m3", style: { ...S.meta, color: "#d66" } }, "⛔ " + meta.blocked_reason)
: null,
];
// g-107:📡 会话实时面板上移至标题与状态摘要下方(默认折叠,点击展开)
// g-109 判据反馈:最新 attempt 无 child_id(子代理启动失败)时也给出「重新执行」兜底区
const att = (d.attempts ?? []).filter((a) => a.child_id).slice(-1)[0];
const anyAtt = (d.attempts ?? []).length > 0;
livePanel = att
? h(SessionPanel, { parentId: att.parent_session_id, childId: att.child_id, collapsible: true,
statusLine: lastAtt?.status_line ?? null,
goalId: props.id, relaunchKind: "exec",
relaunchRoute, onRelaunched: setRelaunchRoute })
: anyAtt
? h("div", { key: "relaunch-fallback", style: { ...S.livePanel, marginTop: 6 } },
h("div", { style: { ...S.meta, marginBottom: 2 } }, "⚠️ 最新子代理未启动/不可用,可换 provider/model 重新派发:"),
h(ReExecBox, { goalId: props.id, kind: "exec", onRelaunched: setRelaunchRoute }))
: null;
// g-a92e1406:tab 内容(占位文案视觉降级:trim 后以「(待」开头 → 小字灰色放标题右侧)
// 识别逻辑:trim 后以「(待」开头 → 占位;若占位后仍有正文,剥离占位行只显示正文
function isPlaceholder(text) {
const t = String(text ?? "").trim();
return t.startsWith("(待");
}
function parsePlaceholder(text) {
const t = String(text ?? "").trim();
if (!t.startsWith("(待")) return { isPh: false, marker: null, body: t };
const m = t.match(/^(待[^)]*)/);
const marker = m ? m[0] : "(待填写)";
const rest = t.replace(/^(待[^)]*)\s*/, "").trim();
return { isPh: true, marker, body: rest };
}
function sectionBlock(key, title, body, extra, hideBodyWhenExtra) {
const { isPh, marker, body: content } = parsePlaceholder(body);
return h("div", { key, style: S.modalSection },
h("div", { style: S.modalH },
title,
isPh && !content ? h("span", { style: { ...S.meta, fontSize: 12, marginLeft: 6, fontWeight: 400 } }, marker) : null),
hideBodyWhenExtra && extra != null ? null : (isPh && !content ? null : content),
extra ?? null);
}
// 判断是否是 backlog 目标(backlog 目标不能建卡)
const isBacklog = d.goalFile && d.goalFile.includes("/backlog/") && !d.goalFile.endsWith("/goal.md");
const detailTab = [
desc != null ? sectionBlock("d", "📋 目标描述", desc,
h(AcceptFeedback, { goalId: props.id, status, events: d.events, supervisorSession: props.supervisorSession, onRefresh: load })) : null,
// g-109:判据栏只在 ready 及之后阶段显示 checklist(已确认可勾选),早期阶段只显示纯文本
crit != null ? sectionBlock("c", "✅ 质量判据", crit,
!isPlaceholder(crit) && ["ready", "in_progress", "review", "delivered"].includes(status)
? h(CriteriaChecklist, { goalId: props.id, crit, att, onClose: props.onClose })
: null, true) : null,
(d.cards ?? []).length
? h("div", { key: "k", style: S.modalSection },
h("div", { style: S.modalH }, "🗂 信息收集"),
d.cards.map((c) => h("div", {
key: c.id,
style: { ...S.subCard, cursor: "pointer" },
className: "dg-sub",
title: "点击打开上下文抽屉",
onClick: (e) => {
e.stopPropagation();
if (props.onOpenCard) {
props.onOpenCard(props.id, c.id);
}
},
}, `${CARD_STATUS_ICON[c.status] ?? c.status} | ${c.title}(${c.kind})`)),
isBacklog
? h("div", { style: { ...S.meta, marginTop: 4 } }, "(backlog 目标不能创建上下文卡片,请先排期)")
: h(AddCardBox, { goalId: props.id, supervisorSession: props.supervisorSession }))
: h("div", { key: "k", style: S.modalSection },
h("div", { style: S.modalH }, "🗂 信息收集"),
h("div", { style: S.meta }, "(暂无上下文卡片)"),
isBacklog
? h("div", { style: { ...S.meta, marginTop: 4 } }, "(backlog 目标不能创建上下文卡片,请先排期)")
: h(AddCardBox, { goalId: props.id, supervisorSession: props.supervisorSession })),
];
// g-150:执行上下文 tab(handoff + 最近指令 + 评论)
const contextTab = [
h(HandoffBox, { key: "hf", goalId: props.id, handoff: d.handoff, attempts: d.attempts, onRefresh: load }),
h(DirectiveBox, { key: "dir", goalId: props.id, directive: d.directive, onRefresh: load }),
h(CommentsBox, { key: "cmt", goalId: props.id, comments: d.comments ?? [], onRefresh: load }),
];
const activityTab = (() => {
const meaningful = (d.events ?? []).filter((e) => MEANINGFUL.has(e.event));
if (!meaningful.length) {
return [h("div", { key: "empty", style: S.meta }, "(暂无近期动态)")];
}
// 简单筛选:按事件类型过滤;排序:按 ts 升/降
const filtered = logFilter ? meaningful.filter((e) => e.event === logFilter) : meaningful;
const sorted = [...filtered].sort((a, b) =>
logSort === "asc" ? String(a.ts).localeCompare(String(b.ts))
: String(b.ts).localeCompare(String(a.ts)));
const typeOptions = [...MEANINGFUL].map((ev) =>
h("option", { key: ev, value: ev }, EVENT_LABEL[ev] ?? ev));
const th = { textAlign: "left", padding: "4px 8px", fontWeight: 700,
borderBottom: "1px solid rgba(128,128,128,.45)", fontSize: 12 };
const td = { padding: "3px 8px", borderBottom: "1px solid rgba(128,128,128,.12)", fontSize: 12 };
return [
// 排序 / 筛选工具条
h("div", { key: "tools", style: { display: "flex", gap: 8, alignItems: "center", marginBottom: 6 } },
h("span", { style: { ...S.meta, fontSize: 11 } }, `共 ${sorted.length} 条`),
h("select", {
value: logFilter,
onChange: (e) => setLogFilter(e.target.value),
style: { fontSize: 12, padding: "2px 6px", cursor: "pointer",
background: "rgba(128,128,128,.10)", color: "inherit",
border: "1px solid rgba(128,128,128,.35)", borderRadius: 4 },
},
h("option", { value: "" }, "全部类型"), ...typeOptions),
h("button", {
onClick: () => setLogSort(logSort === "asc" ? "desc" : "asc"),
style: { ...S.btn, border: "1px solid rgba(128,128,128,.35)", borderRadius: 4 },
}, logSort === "asc" ? "↑ 时间正序" : "↓ 时间倒序")),
// 事件日志表格:时间 / 事件 / 执行者
h("table", { key: "tbl", style: { width: "100%", borderCollapse: "collapse" } },
h("thead", null, h("tr", null,
h("th", { style: th }, "时间"),
h("th", { style: th }, "事件"),
h("th", { style: th }, "执行者"))),
h("tbody", null,
sorted.map((e, i) => {
const { when, what, who } = eventParts(e);
return h("tr", { key: i, style: i % 2 ? { background: "rgba(128,128,128,.05)" } : undefined },
h("td", { style: { ...td, whiteSpace: "nowrap", opacity: 0.85 } }, when),
h("td", { style: td }, what),
h("td", { style: { ...td, whiteSpace: "nowrap", opacity: 0.7 } }, who));
}))),
];
})();
content = [
// g-a92e1406:tab 切换栏(页签式——选中页签与下方面板同底色、无下边框、下移覆盖分隔线,
// 从面板"长出"形成视觉关联;未选中页签扁平透明,区别于普通按钮)
h("div", {
key: "tabs",
style: { display: "flex", gap: 4, marginTop: 12, alignItems: "flex-end",
borderBottom: "1px solid rgba(128,128,128,.35)" },
className: "dg-tab",
},
h("button", {
style: {
fontSize: 12, padding: "5px 14px", cursor: "pointer",
marginBottom: -1, borderRadius: "6px 6px 0 0",
border: "1px solid " + (tab === "detail" ? "rgba(128,128,128,.35)" : "transparent"),
borderBottom: "none",
background: tab === "detail" ? "rgba(128,128,128,.10)" : "transparent",
fontWeight: tab === "detail" ? 700 : 400,
color: tab === "detail" ? "#8ab4ff" : "inherit",
opacity: tab === "detail" ? 1 : 0.7,
},
onClick: () => setTab("detail"),
}, "📋 详情"),
h("button", {
style: {
fontSize: 12, padding: "5px 14px", cursor: "pointer",
marginBottom: -1, borderRadius: "6px 6px 0 0",
border: "1px solid " + (tab === "activity" ? "rgba(128,128,128,.35)" : "transparent"),
borderBottom: "none",
background: tab === "activity" ? "rgba(128,128,128,.10)" : "transparent",
fontWeight: tab === "activity" ? 700 : 400,
color: tab === "activity" ? "#8ab4ff" : "inherit",
opacity: tab === "activity" ? 1 : 0.7,
},
onClick: () => setTab("activity"),
}, "🕘 近期动态"),
h("button", {
style: {
fontSize: 12, padding: "5px 14px", cursor: "pointer",
marginBottom: -1, borderRadius: "6px 6px 0 0",
border: "1px solid " + (tab === "context" ? "rgba(128,128,128,.35)" : "transparent"),
borderBottom: "none",
background: tab === "context" ? "rgba(128,128,128,.10)" : "transparent",
fontWeight: tab === "context" ? 700 : 400,
color: tab === "context" ? "#8ab4ff" : "inherit",
opacity: tab === "context" ? 1 : 0.7,
},
onClick: () => setTab("context"),
}, "📌 执行上下文"),
// g-129: goal.md 链接放在 tab 行右侧
d.goalFile
? h("div", { style: { marginLeft: "auto", display: "flex", alignItems: "center", gap: 4, marginBottom: 1 } },
h("span", { style: { fontSize: 11, opacity: 0.7 } }, "📄 goal.md"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" },
className: "dg-btn",
title: "用系统默认编辑器打开 goal.md",
onClick: async (e) => {
e.stopPropagation();
try {
const conn = connectionRt ?? appCtx?.get?.("connection");
if (conn?.api?.host?.openPath) {
const result = await conn.api.host.openPath({ path: d.goalFile });
if (result?.opened) { showToast("✅ 已打开 goal.md"); return; }
}
await copyText(d.goalFile);
showToast("✅ 路径已复制(打开不可用)");
} catch {
await copyText(d.goalFile);
showToast("✅ 路径已复制");
}
},
}, "打开"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" },
className: "dg-btn",
title: "复制 goal.md 路径",
onClick: async (e) => { e.stopPropagation(); const ok = await copyText(d.goalFile); if (ok) showToast("✅ 路径已复制"); },
}, "复制路径"))
: null),
// 面板容器:与页签一体(上边框由 tab 栏分隔线承接),包住当前 tab 内容
h("div", {
key: "panel",
style: { border: "1px solid rgba(128,128,128,.35)", borderTop: "none",
borderRadius: "0 6px 6px 6px", padding: "10px 12px",
background: "rgba(128,128,128,.06)" },
}, tab === "detail" ? detailTab : tab === "context" ? contextTab : activityTab),
];
}
const doRename = async () => {
const t = newTitle.trim();
if (!t) { setRenameNote("标题不能为空"); return; }
if (t === (props.title ?? props.id)) { setRenaming(false); return; }
try {
const r = await fetch(graphUrl("/api/dsh-graph/rename-goal"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: props.id, title: t }),
});
const data = await r.json();
if (data.ok) {
setRenaming(false);
setRenameNote(null);
// 刷新详情数据
const goalRes = await fetch(graphUrl("/api/dsh-graph/goal", { id: props.id }));
const goalData = await goalRes.json();
if (!goalData.error) setState({ loading: false, data: goalData });
// 触发父组件刷新看板
if (props.onRenamed) props.onRenamed(props.id, t);
} else {
setRenameNote("⚠️ 重命名失败:" + (data.error || "未知错误"));
}
} catch (e) {
setRenameNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
};
// g-110: 归档/取消归档操作
const [archiveNote, setArchiveNote] = React.useState(null);
const isArchived = state.data?.meta?.archived === true;
const canArchive = ["draft", "planning", "delivered"].includes(state.data?.meta?.status);
// g-140: 删除操作(仅已归档目标可删除,二次确认)
const [deleteConfirm, setDeleteConfirm] = React.useState(false);
const [deleteNote, setDeleteNote] = React.useState(null);
const doArchive = async () => {
try {
const r = await fetch(graphUrl("/api/dsh-graph/archive"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: props.id }),
});
const data = await r.json();
if (data.ok) {
setArchiveNote("✅ 已归档");
showToast("✅ 目标已归档");
props.onArchived?.(); // 刷新看板:归档后卡片立即消失
// 刷新详情
const goalRes = await fetch(graphUrl("/api/dsh-graph/goal", { id: props.id }));
const goalData = await goalRes.json();
if (!goalData.error) setState({ loading: false, data: goalData });
} else {
setArchiveNote("⚠️ 归档失败:" + (data.error || "未知错误"));
}
} catch (e) {
setArchiveNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
};
const doUnarchive = async () => {
try {
const r = await fetch(graphUrl("/api/dsh-graph/unarchive"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: props.id }),
});
const data = await r.json();
if (data.ok) {
setArchiveNote("✅ 已取消归档");
showToast("✅ 已取消归档");
props.onArchived?.(); // 刷新看板:取消归档后卡片回到看板
// 刷新详情
const goalRes = await fetch(graphUrl("/api/dsh-graph/goal", { id: props.id }));
const goalData = await goalRes.json();
if (!goalData.error) setState({ loading: false, data: goalData });
} else {
setArchiveNote("⚠️ 取消归档失败:" + (data.error || "未知错误"));
}
} catch (e) {
setArchiveNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
};
// g-140: 删除已归档目标(仅已归档目标可删除,二次确认)
const doDelete = async () => {
try {
const r = await fetch(graphUrl("/api/dsh-graph/delete"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ goal: props.id }),
});
const data = await r.json();
if (data.ok) {
setDeleteNote("✅ 已删除");
showToast("✅ 目标已删除");
props.onArchived?.(); // 刷新看板:删除后卡片立即消失
setDeleteConfirm(false);
} else {
setDeleteNote("⚠️ 删除失败:" + (data.error || "未知错误"));
}
} catch (e) {
setDeleteNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
};
const titleEl = renaming
? h("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 4 } },
h("span", null, "🎯"),
h("input", {
style: { ...S.promptInput, flex: 1, fontSize: 15, fontWeight: 700 },
value: newTitle,
onChange: (e) => setNewTitle(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") doRename(); if (e.key === "Escape") setRenaming(false); },
autoFocus: true,
}),
h("button", {
style: { ...S.btn, padding: "2px 10px" }, className: "dg-btn",
onClick: doRename,
}, "确认"),
h("button", {
style: { ...S.btn, padding: "2px 10px" }, className: "dg-btn",
onClick: () => { setRenaming(false); setRenameNote(null); },
}, "取消"),
renameNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, renameNote) : null)
: h("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } },
h("span", { style: { fontWeight: 700, fontSize: 15 } }, `🎯 ${props.title ?? props.id}`),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px", opacity: 0.7 }, className: "dg-btn",
title: "重命名目标",
onClick: (e) => { e.stopPropagation(); setNewTitle(props.title ?? props.id); setRenaming(true); setRenameNote(null); },
}, "✏️"),
// g-110: 归档/取消归档按钮
isArchived
? h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(58,166,117,.2)" }, className: "dg-btn",
title: "取消归档(恢复到原位置)",
onClick: doUnarchive,
}, "📤 取消归档")
: canArchive
? h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(128,128,128,.2)" }, className: "dg-btn",
title: "归档目标(仅 draft/planning/delivered 可归档)",
onClick: doArchive,
}, "📦 归档")
: null,
// g-140: 删除按钮(仅已归档目标显示,二次确认)
isArchived
? (deleteConfirm
? h("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, marginLeft: 2 } },
h("span", { style: { ...S.meta, fontSize: 11, color: "#d66" } }, "确认删除?"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(214,102,102,.3)" }, className: "dg-btn",
title: "确认删除(不可恢复)",
onClick: doDelete,
}, "🗑 确认"),
h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, className: "dg-btn",
onClick: () => { setDeleteConfirm(false); setDeleteNote(null); },
}, "取消"))
: h("button", {
style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(214,102,102,.2)" }, className: "dg-btn",
title: "删除目标(仅已归档目标可删除,含卡片/attempts)",
onClick: () => { setDeleteConfirm(true); setDeleteNote(null); },
}, "🗑 删除"))
: null,
archiveNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, archiveNote) : null,
deleteNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, deleteNote) : null);
return h(
"div",
{ style: S.overlay, onClick: props.onClose },
h("div", { style: S.modal, onClick: (e) => e.stopPropagation() },
h("span", { style: S.close, onClick: props.onClose }, "✕"),
titleEl,
headMeta,
livePanel,
content),
);
}
// g-77647351:回退询问理由弹窗(后→前方向拖动时)
// 判据 4:有子代理 → 作为子代理消息补充(send_message);无子代理 → 补充给主管
wrap: { padding: 12, fontSize: 13, color: "inherit", overflowX: "auto" },
head: { display: "flex", alignItems: "center", gap: 12, marginBottom: 8 },
grid: { display: "grid", gridTemplateColumns: "130px repeat(6, minmax(150px, 1fr))", gap: 4 },
laneLabel: { fontWeight: 600, padding: "8px 6px", borderTop: "1px solid rgba(128,128,128,.35)" },
stageHead: { fontWeight: 600, textAlign: "center", padding: 4, opacity: 0.75, whiteSpace: "nowrap" },
cell: { borderTop: "1px solid rgba(128,128,128,.35)", padding: 4, minHeight: 40, verticalAlign: "top" },
goalCard: {
border: "1px solid rgba(128,128,128,.45)",
borderLeft: "5px solid #4c8dff",
borderRadius: 6, padding: "6px 8px", marginBottom: 6,
background: "rgba(128,128,128,.08)", cursor: "pointer",
},
depCard: { borderLeft: "5px solid #e0a53a" },
blockedCard: { borderLeft: "5px solid #d66" },
subCard: {
border: "1px solid rgba(128,128,128,.35)",
borderLeft: "4px solid #3aa675",
borderRadius: 5, padding: "2px 6px", margin: "4px 0 0 10px",
fontSize: 11, opacity: 0.9,
},
title: { fontWeight: 600, marginBottom: 2 },
meta: { opacity: 0.65, fontSize: 12 },
statusLine: { fontStyle: "italic", opacity: 0.85, marginTop: 3 },
collapsed: {
padding: "6px", opacity: 0.75, cursor: "pointer", userSelect: "none",
borderTop: "1px dashed rgba(128,128,128,.35)",
},
overlay: {
position: "fixed", inset: 0, background: "rgba(0,0,0,.55)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999,
},
drawer: {
position: "fixed", top: 0, right: 0, height: "100vh", width: 400,
background: "#1e1f24", color: "#e6e6e6", zIndex: 10000,
boxShadow: "-4px 0 16px rgba(0,0,0,.45)",
padding: "20px 22px", overflowY: "auto", fontSize: 13, lineHeight: 1.7,
fontFamily: "inherit",
},
drawerSection: { marginTop: 14 },
drawerH: { fontWeight: 700, fontSize: 13, marginBottom: 6, opacity: 0.9 },
modal: {
background: "#1e1f24", color: "#e6e6e6", borderRadius: 10,
maxWidth: 720, width: "90%", maxHeight: "80vh", overflowY: "auto",
padding: "16px 20px", fontSize: 13, lineHeight: 1.6,
},
modalSection: { marginTop: 10, whiteSpace: "pre-wrap" },
modalH: { fontWeight: 700, marginBottom: 4 },
btn: { fontSize: 12, padding: "2px 10px", cursor: "pointer" },
close: { float: "right", cursor: "pointer", opacity: 0.7, fontSize: 16 },
// g-107 会话内嵌实时区
liveStrip: {
marginTop: 4, padding: "3px 6px", borderRadius: 4,
background: "rgba(76,141,255,.10)", fontSize: 11, lineHeight: 1.6,
},
liveLine: {
marginTop: 2, opacity: 0.85, whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis", maxWidth: "100%",
},
livePanel: {
marginTop: 10, padding: 8, border: "1px solid rgba(76,141,255,.35)",
borderRadius: 6, background: "rgba(76,141,255,.06)",
},
promptBox: { marginTop: 4 },
promptRow: { display: "flex", gap: 4, alignItems: "center" },
promptInput: {
flex: 1, minWidth: 0, fontSize: 12, padding: "3px 6px",
background: "rgba(0,0,0,.25)", color: "inherit",
border: "1px solid rgba(128,128,128,.4)", borderRadius: 4,
},
// g-108 看板顶部 supervisor 状态栏
supervisorBar: {
display: "flex", alignItems: "center", gap: 10, marginBottom: 8,
padding: "4px 10px", border: "1px solid rgba(58,166,117,.45)",
borderRadius: 6, background: "rgba(30,31,36,.92)", fontSize: 12,
},
recordItem: {
marginTop: 3, padding: "3px 6px", borderRadius: 4,
background: "rgba(128,128,128,.10)", whiteSpace: "pre-wrap",
wordBreak: "break-word", fontSize: 12,
},
};
function stageOf(status) {
for (const s of STAGES) if (s.statuses.includes(status)) return s.key;
return "describe";
}
// g-77647351:拖放辅助函数
/** Pointer-position half of a card (insert line above or below). */
function rowHalf(e) {
const rect = e.currentTarget.getBoundingClientRect();
return e.clientY < rect.top + rect.height / 2 ? "before" : "after";
}
/** 将列 key 映射回一个代表状态(用于 transition 目标) */
function stageDefaultStatus(stageKey) {
const stage = STAGES.find((s) => s.key === stageKey);
return stage ? stage.statuses[0] : null;
}
/** 跨列拖动时解析目标状态:from+toStageKey → 具体 to 状态 */
function resolveTargetStatus(fromStatus, toStageKey) {
// blocked 只能回 blocked_from(由服务端强制,前端预判提示)
if (fromStatus === "blocked") return null; // 前端不预设,服务端校验
// planning→collect 二义默认 collecting
if (toStageKey === "collect") return "collecting";
if (toStageKey === "describe") return "planning";
return stageDefaultStatus(toStageKey);
}
/** 判断是否为回退方向(后→前,如 delivered→execute) */
const STAGE_ORDER = STAGES.map((s) => s.key);
function isBackward(fromStatus, toStatus) {
const fromStage = stageOf(fromStatus);
const toStage = stageOf(toStatus);
if (fromStage === toStage) return false;
// delivered 终态特殊:任何离开 delivered 的方向都是回退(但 delivered 无出边,服务端会拒)
return STAGE_ORDER.indexOf(toStage) < STAGE_ORDER.indexOf(fromStage);
}
const CARD_STATUS_ICON = { empty: "○ 待收集", collecting: "◌ 收集中", filled: "● 已填充", reviewed: "✔ 已复核" };
// ===== g-107 会话内嵌实时:复用 DSH 客户端会话机制,不自建数据通道 =====

Sorry, the diff of this file is too big to display

const snap = useSessionSnapshot(session);
const usage = useProjectionValue(session, "tokenUsage");
const pressure = useProjectionValue(session, "contextPressure");
if (!props.childId) return null;
if (!session) {
return h("div", { style: S.liveStrip, title: props.childId },
"🔌 会话未接入(不在会话列表):" + props.childId.slice(0, 8));
}
const running = !!(snap && snap.running);
// g-a92e1406 追加(负责人指示):新一轮开始(running false→true)时清空上次 status,
// 等 supervisor 快速替换成最新——记录 running 翻转时刻,旧于它的状态视为过期清空。
const runningSinceRef = React.useRef(null);
React.useEffect(() => {
if (running && runningSinceRef.current == null) runningSinceRef.current = Date.now();
if (!running) runningSinceRef.current = null;
}, [running]);
const staleStatus =
running && props.statusAt != null && runningSinceRef.current != null &&
props.statusAt < runningSinceRef.current;
// g-124(负责人 2026-08-22):staleStatus 不再用等待占位文案——
// 改为显示当前状态延续时长(statusAt 距今多久,行内 + tooltip);30s 时钟驱动刷新。
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!staleStatus) return;
const t = setInterval(() => setNow(Date.now()), 30000);
return () => clearInterval(t);
}, [staleStatus]);
const staleDur = staleStatus && props.statusAt != null ? fmtElapsed(props.statusAt, now) : null;
const line = snap && snap.chat ? lastStreamLine(snap.chat.legacy.partial) : null;
const meter = liveMeter(usage, pressure);
// g-129 负责人 2026-08-22 格式:第一行 = 状态 + 流式内容(同行,流式时有时无不引起高度变化),
// 右侧有足够宽度时显示 tok/ctx;第二行 = status_line 固定显示。
const statusLabel = running ? "🟢 运行中" : "⚪ 空闲";
const statusFull = running ? "运行中" : "空闲";
// 第二行 status_line 内容(stale 时也显示全文,tooltip 补延续时长——g-124)
const statusRowText = props.statusLine
? (running ? "⏳ " : "✅ ") + props.statusLine
: (staleStatus ? "⏳ 状态延续 " + staleDur : null);
// g-129: 空闲时 status_line 背景不带动画
const statusRowClass = running && props.statusLine ? "dg-running-flow" : "";
const lineEl = line
? h("span", { style: { ...S.meta, fontSize: 10, overflow: "hidden",
textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 } },
"⏵ " + line)
: h("span", { style: { ...S.meta, fontSize: 10, flex: 1, overflow: "hidden",
textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "…");
return h(
"div",
{ style: S.liveStrip, title: [statusFull, props.statusLine ? "状态:" + props.statusLine : null, meter ? "资源:" + meter : null, line ? "流式:" + line : null].filter(Boolean).join("\n") },
// 第一行:状态 + 流式内容(同行);右侧有空间时显示 tok/ctx(flex 布局自动压缩)
h("div", { style: { display: "flex", alignItems: "center", gap: 5 } },
h("span", { style: { color: running ? "#3aa675" : "rgba(128,128,128,.9)", flexShrink: 0 } },
statusLabel),
lineEl,
meter
? h("span", { style: { ...S.meta, fontSize: 10, flexShrink: 0, marginLeft: 4 } }, meter)
: null),
// 第二行:status_line 固定显示(stale 时也显示全文)
statusRowText
? h("div", {
className: statusRowClass,
style: { ...S.liveLine, marginTop: 1, fontSize: 10, overflow: "hidden",
textOverflow: "ellipsis", whiteSpace: "nowrap" },
title: props.statusLine ? props.statusLine + (staleDur ? "(状态已延续 " + staleDur + ")" : "") : undefined,
}, statusRowText)
: null,
);
}
// 看板直达指令:向 continuable 子代理发文本(queue 排队 / steer 插队)。
// 多模态降级:子代理图片源码级不支持(SUBAGENT_IMAGE_UNSUPPORTED)——明确提示而非静默失败。
function PromptBox(props) {
const { session, mode } = useBoundSession(props.parentId, props.childId);
const [text, setText] = React.useState("");
const [note, setNote] = React.useState(null);
if (!props.childId || !session) return null;
if (mode === "one-shot") {
return h("div", { style: { ...S.meta, marginTop: 3 } },
"📦 一次性子代理会话为只读,不能续发指令");
}
const send = async (sendMode) => {
const t = text.trim();
if (!t) return;
setNote("发送中…");
try {
// session.prompt:continuable 子代理自动路由 api.subagents.prompt(仅文本)
const res = await session.prompt([{ type: "text", text: t }], sendMode);
if (res?.ok) {
setText("");
setNote(sendMode === "steer" ? "✅ 已插队发送" : "✅ 已排队");
} else {
const err = res?.error ?? {};
const reason = String(err?.details?.reason ?? err?.code ?? "");
if (reason.includes("SUBAGENT_IMAGE_UNSUPPORTED"))
setNote("⚠️ 子代理会话不支持图片等多模态输入(SUBAGENT_IMAGE_UNSUPPORTED),请改用纯文本");
else setNote("⚠️ 发送失败:" + (err?.message ?? reason ?? "未知错误"));
}
} catch (e) {
setNote("⚠️ 发送异常:" + (e?.message ?? e));
}
};
return h(
"div",
{ style: S.promptBox, onClick: (e) => e.stopPropagation() },
h("div", { style: S.promptRow },
h("input", {
style: S.promptInput,
value: text,
placeholder: "直达指令:发送到该子代理会话…",
onChange: (e) => setText(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") send("queue"); },
}),
h("button", {
style: { ...S.btn, flexShrink: 0 }, className: "dg-btn",
title: "追加到会话队列尾部(queue)", onClick: () => send("queue"),
}, "排队"),
h("button", {
style: { ...S.btn, flexShrink: 0 }, className: "dg-btn",
title: "打断当前输出立即执行(steer)", onClick: () => send("steer"),
}, "插队")),
h("div", { style: { ...S.meta, fontSize: 10 } },
"仅文本:子代理会话不支持图片等多模态输入(SUBAGENT_IMAGE_UNSUPPORTED)",
note ? " | " + note : ""),
);
}
// 最近会话记录:api.subagents.history(无父会话时退化 api.sessions.history)
function RecentRecords(props) {
const [state, setState] = React.useState({ loading: true });
React.useEffect(() => {
if (!connectionRt) { setState({ loading: false, error: "connection 服务不可用" }); return; }
let alive = true;
const call = props.parentId
? connectionRt.api.subagents.history({
parentSessionId: props.parentId, childSessionId: props.childId,
mode: props.mode ?? "continuable", maxMessages: 30,
})
: connectionRt.api.sessions.history({ sessionId: props.childId, maxMessages: 30 });
call
.then((r) => alive && setState(r?.result?.ok
? { loading: false, entries: r.result.value.events }
: { loading: false, error: r?.result?.error?.message ?? "读取失败" }))
.catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) }));
return () => { alive = false; };
}, [props.parentId, props.childId]);
const entryText = (entry) => {
const ev = entry?.event ?? {};
const d = ev.data ?? {};
if (ev.type === "user/message" || ev.type === "assistant/message") {
const parts = (Array.isArray(d.content) ? d.content : [])
.map((b) => b.type === "text" ? b.text
: b.type === "reasoning" ? "💭" + String(b.text ?? "").slice(0, 120)
: "[" + (b.type ?? "?") + "]")
.join("");
return (ev.type === "user/message" ? "🧑 " : "🤖 ") + (parts.trim().slice(0, 400) || "(空消息)");
}
if (ev.type === "assistant/tool-call") return "🔧 " + (d.name ?? "tool");
return "· " + (ev.type ?? "未知事件");
};
let body;
if (state.loading) body = "读取中…";
else if (state.error) body = "读取失败:" + state.error;
else if (!state.entries.length) body = "(无记录)";
else body = state.entries.slice(-12).map((e, i) =>
h("div", { key: i, style: S.recordItem }, entryText(e)));
return h("div", { style: { marginTop: 4, fontSize: 12 } }, body);
}
// 当前模型查询(api.sessions.models,无投影走 RPC;30s 轮询)。
// origin=subagent 的子会话被 host 围栏拒绝(agent-busy: owned by subagent routing,
// dsh-api-remotes 源码级设计),此时退化查询父会话并标注 fromParent。
// 查询某会话的当前模型选择。
// g-109 判据反馈:子代理会话的 models 查询常失败(continuable idle 后无 live agent),
// 旧逻辑回退父会话会把「父会话模型」冒充子代理实际模型(如用 flash 派发却显示 v4-pro),
// 误导负责人。现改为失败即报错,由调用方用「重新执行指定路由」或「查询不可用」兜底。
function useSessionModel(sessionId, parentId) {
const [model, setModel] = React.useState(null); // {provider, model, fromParent}
const [modelErr, setModelErr] = React.useState(null);
React.useEffect(() => {
if (!sessionId || !connectionRt) return;
let alive = true;
const load = async () => {
try {
const r = await connectionRt.api.sessions.models({ sessionId });
if (!alive) return;
if (r?.result?.ok) {
setModel({ ...(r.result.value.current ?? {}), fromParent: false });
setModelErr(null);
} else {
setModel(null);
setModelErr(r?.result?.error?.message ?? "查询失败");
}
} catch (e) {
if (alive) { setModel(null); setModelErr(String(e?.message ?? e)); }
}
};
load();
const t = setInterval(load, 30000);
return () => { alive = false; clearInterval(t); };
}, [sessionId, parentId]);
return { model, modelErr };
}
// 完整实时面板(抽屉/详情用):实时条 + 模型 + 直达指令 + 最近记录。
// collapsible=true 时默认折叠,点击标题行展开;折叠态标题行内联显示状态/token/模型摘要。
// g-109 判据反馈:实时会话控件中的「重新执行」——选择 provider/model 重新拉一个子代理。
// kind="exec" → start-execution(目标执行子代理);kind="collect" → start-collection(卡片收集子代理,需 cardId+prompt)。
// 下拉数据源 = spawn-options 的 modelGroups(LLM provider 分组目录);subagent provider(spawn/fork)不暴露给用户。
function ReExecBox(props) {
const { goalId, kind, cardId, prompt } = props;
const [opts, setOpts] = React.useState(null); // {modelGroups, default}
const [provider, setProvider] = React.useState("");
const [model, setModel] = React.useState("");
const [note, setNote] = React.useState(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
let alive = true;
fetch(graphUrl("/api/dsh-graph/spawn-options"))
.then((r) => r.json())
.then((d) => {
if (!alive) return;
setOpts(d);
// g-109 判据反馈:默认 = project.yaml executor(spawn-options.default);
// provider 不在目录 → 选第一个;model 默认取 project.yaml,若不在所选 provider
// 的模型清单 → 选该清单第一个(不再出现「模型写死」且 provider/model 失配)。
const groups = d?.modelGroups ?? [];
const defP = d?.default?.provider ?? "";
const defM = d?.default?.model ?? "";
const effProvider = groups.some((g) => g.id === defP) ? defP : (groups[0]?.id ?? "");
const g0 = groups.find((x) => x.id === effProvider);
const ms = g0?.models ?? [];
const effModel = ms.some((m) => m.id === defM) ? defM : (ms[0]?.id ?? "");
setProvider(effProvider);
setModel(effModel);
})
.catch(() => alive && setOpts({ modelGroups: null, default: null }));
return () => { alive = false; };
}, []);
const groups = opts?.modelGroups ?? [];
const currentGroup = groups.find((g) => g.id === provider) ?? null;
const modelChoices = currentGroup?.models ?? [];
const relaunch = async () => {
setBusy(true);
setNote("重新派发中…");
try {
const url = kind === "collect" ? "/api/dsh-graph/start-collection" : "/api/dsh-graph/start-execution";
const body = { goal: goalId, provider: provider || undefined, model: model || undefined };
if (kind === "collect") { body.card = cardId; body.prompt = prompt; }
const r = await fetch(graphUrl(url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json();
if (data.ok) {
if (data.child_id) {
const route = data.model_route ? `(${data.model_route})` : "";
setNote("✅ 已重新派发子代理,id:" + data.child_id + " " + route);
showToast("✅ 已重新派发子代理 " + route);
if (data.model_route) props.onRelaunched?.(data.model_route);
} else {
setNote("⚠️ 子代理启动失败:" + (data.child_error || "无 child_id"));
}
} else {
setNote("⚠️ 派发失败:" + (data.error || "未知错误"));
}
} catch (e) {
setNote("⚠️ 请求失败:" + String(e?.message ?? e));
}
setBusy(false);
};
const selStyle = {
fontSize: 12, padding: "2px 6px", cursor: "pointer", maxWidth: 160,
background: "rgba(128,128,128,.10)", color: "inherit",
border: "1px solid rgba(128,128,128,.35)", borderRadius: 4,
};
// 深色主题:浏览器原生 option 默认白底,下拉展开时突兀 → 显式深色底
const optStyle = { background: "#2a2b31", color: "#e6e6e6" };
const defP = opts?.default?.provider ?? "";
const defM = opts?.default?.model ?? "";
// 无模型目录(llm 服务不可用):只显示默认模型 + 提示,仍可重新派发(走 project.yaml 默认)
const noCatalog = !groups.length;
return h("div", { style: { marginTop: 6, display: "flex", flexDirection: "column", gap: 4 } },
h("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
h("span", { style: { ...S.meta, fontSize: 11 } }, "🔄 重新派发子代理:"),
noCatalog
? h("span", { style: { ...S.meta, fontSize: 11 } },
defP ? `模型 ${defP}/${defM}` : "模型目录不可用")
: [
h("select", {
style: selStyle, value: provider,
title: "LLM provider(缺省 project.yaml executor.provider)",
onChange: (e) => { setProvider(e.target.value); setModel(""); },
},
groups.map((g) => h("option", { key: g.id, value: g.id, style: optStyle }, g.name ?? g.id))),
h("select", {
style: selStyle, value: model,
disabled: !modelChoices.length,
title: "模型(缺省 project.yaml executor.model)",
onChange: (e) => setModel(e.target.value),
},
!modelChoices.length
? h("option", { value: defM, style: optStyle }, defM ? `默认 ${defM}` : "model 不可用")
: [h("option", { key: "", value: "", style: optStyle }, "默认"),
...modelChoices.map((m) => h("option", { key: m.id, value: m.id, style: optStyle }, m.name ?? m.id))]),
],
h("button", {
style: { ...S.btn, padding: "3px 10px", fontSize: 12 }, className: "dg-btn dg-relaunch",
disabled: busy, onClick: relaunch,
}, busy ? "派发中…" : (kind === "collect" ? "🔄 重新收集" : "🔄 重新执行"))),
note ? h("div", { style: { ...S.meta, marginTop: 2 } }, note) : null,
);
}
function SessionPanel(props) {
const collapsible = !!props.collapsible;
const [open, setOpen] = React.useState(!collapsible);
const { session, mode } = useBoundSession(props.parentId, props.childId);
const snap = useSessionSnapshot(session);
const usage = useProjectionValue(session, "tokenUsage");
const pressure = useProjectionValue(session, "contextPressure");
const { model, modelErr } = useSessionModel(props.childId, props.parentId);
const [showRecords, setShowRecords] = React.useState(false);
const running = !!(snap && snap.running);
const meter = liveMeter(usage, pressure);
const statusLine = props.statusLine ?? null;
const statusLabel = running ? "🟢 运行中" : "⚪ 空闲";
// g-109 判据反馈:sessions.models 对子代理查询失败时,用「重新执行指定路由」兜底(绝不用父会话模型冒充)
const relaunchRoute = props.relaunchRoute ?? null;
const modelText = model
? `${model.provider}/${model.model}` + (model.fromParent ? "(父会话,子代理继承)" : "")
: relaunchRoute ? `按重新执行指定:${relaunchRoute}` : modelErr ? "不可用:" + modelErr : "查询中…";
// 折叠态标题行的内联摘要:状态 + statusLine + token/ctx + 模型短名
const collapsedBits = [
statusLabel,
statusLine ? (running ? "⏳ " : "✅ ") + statusLine : null,
meter || null,
model ? model.model : relaunchRoute ? "重派:" + String(relaunchRoute).split("/").pop() : null,
].filter(Boolean).join(" | ");
return h(
"div",
{ style: S.livePanel },
h("div", {
style: { ...S.drawerH, display: "flex", alignItems: "center", gap: 6,
cursor: collapsible ? "pointer" : "default", userSelect: "none" },
title: collapsible ? (open ? "点击收起" : "点击展开") : undefined,
onClick: collapsible ? () => setOpen(!open) : undefined,
},
h("span", { style: { flexShrink: 0 } },
(collapsible ? (open ? "▾ " : "▸ ") : "") + "📡 实时会话"),
collapsible && !open
? h("span", { style: { ...S.meta, fontSize: 11, flex: 1, minWidth: 0,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
collapsedBits || "(无状态)")
: h("span", { style: { flex: 1 } }),
sessionLinkBtn(props.parentId, props.childId, "↗ 转到对话")),
!open ? null : [
h(LiveStrip, { key: "s", parentId: props.parentId, childId: props.childId,
statusLine }),
h("div", { key: "m", style: { ...S.meta, marginTop: 3 } },
"模型:" + modelText
+ (mode ? ` | 模式:${mode === "continuable" ? "可续轮" : "一次性"}` : "")),
h(PromptBox, { key: "p", parentId: props.parentId, childId: props.childId }),
// g-109 判据反馈:实时会话控件内「重新执行」——子代理出错/无法运行时换 provider/model 重拉
props.goalId
? h(ReExecBox, { key: "rx", goalId: props.goalId, kind: props.relaunchKind ?? "exec",
cardId: props.relaunchCardId, prompt: props.relaunchPrompt,
onRelaunched: props.onRelaunched })
: null,
h("div", { key: "r", style: { marginTop: 6 } },
h("button", {
style: S.btn, className: "dg-btn",
onClick: () => setShowRecords(!showRecords),
}, showRecords ? "▾ 收起最近记录" : "▸ 查看最近会话记录")),
showRecords
? h(RecentRecords, { key: "rr", parentId: props.parentId, childId: props.childId, mode })
: null,
],
);
}
// g-108 看板顶部 supervisor 状态栏:复用 LiveStrip(运行/空闲、最新流式行、tok/ctx)
// + 模型名(useSessionModel,顶层会话直接查)+ 一键跳转主管对话。
// 会话 id 来自 board 端点下发的 supervisorSession(project.yaml),不硬编码。
// g-a92e1406 判据 3① 扩展:statusLine 传 supervisor 自己的 status_line(事件流最新一条),
// 运行中由 LiveStrip 走 StatusLine 带动画(流动背景 + 图标 pulse)。
// g-129 修复:缓存最近一次成功解析的 workspace——切到子代理会话(不在 workspace 映射、
// 无 cwd/parentSessionId 可用)时回退缓存,避免看板读 process.cwd() 空骨架而空白。
let lastGoodWorkspace = null;
function setLastGoodWorkspace(ws) { if (ws) lastGoodWorkspace = ws; }
// g-113 定点 bug 2:workspace 数据源改用 workspaces 服务——sessions 条目 cwd 并非总有
// (DSH 源码 dsh-client-runtime/client.js:9233 `...entry.cwd !== void 0 ? { cwd: entry.cwd } : {}`,
// aseit-ella 会话条目 cwd 为空),可靠来源是 workspaces 服务:
// `workspaces.list.getSnapshot().items` 每条 `{ workspaceId, path, title, sessionIds, ... }`
// (host workspaceView:dsh-host-apiproxy lib 793-801;runtime project() 直接透传 items),
// path 即该 workspace 目录,`sessionIds.includes(被查看会话)` 即归属映射
// (同文件 :9866 `summary.cwd === workspace.path && workspace.sessionIds.includes(summary.id)`)。
// 优先级:被查看会话(workspaces)→ 被查看会话(sessions cwd)→ list.current(workspaces)
// → list.current(sessions cwd)→ null(裸路径,端点兜底 process.cwd())。
function currentWorkspace() {
try {
const wsItems = workspacesRt?.list?.getSnapshot?.()?.items
?? appCtx?.get?.("workspaces")?.list?.getSnapshot?.()?.items ?? [];
const wsOf = (sid) => wsItems.find?.((w) => w.sessionIds.includes(sid));
const rt = sessionsRt ?? appCtx?.get?.("sessions");
const snap = rt?.list?.getSnapshot?.();
const items = snap?.items ?? [];
if (viewedSessionId) {
const w = wsOf(viewedSessionId);
if (w?.path) { setLastGoodWorkspace(w.path); return w.path };
const viewed = items.find?.((s) => s.sessionId === viewedSessionId);
if (viewed?.cwd) { setLastGoodWorkspace(viewed.cwd); return viewed.cwd };
// g-129 修复(负责人 2026-08-22):子代理会话不在 workspace 映射且无 cwd 时,
// 沿 parentSessionId 链回溯父会话的 workspace(子代理继承父会话 workspace)
if (viewed?.parentSessionId) {
const parent = items.find?.((s) => s.sessionId === viewed.parentSessionId);
if (parent?.cwd) { setLastGoodWorkspace(parent.cwd); return parent.cwd };
const pw = wsOf(viewed.parentSessionId);
if (pw?.path) { setLastGoodWorkspace(pw.path); return pw.path };
}
}
const current = snap?.current;
if (current) {
const w = wsOf(current);
if (w?.path) { setLastGoodWorkspace(w.path); return w.path };
const item = items.find?.((s) => s.sessionId === current);
if (item?.cwd) { setLastGoodWorkspace(item.cwd); return item.cwd };
// g-129 修复:current 是子代理时回溯父会话 workspace
if (item?.parentSessionId) {
const parent = items.find?.((s) => s.sessionId === item.parentSessionId);
if (parent?.cwd) { setLastGoodWorkspace(parent.cwd); return parent.cwd };
const pw = wsOf(item.parentSessionId);
if (pw?.path) { setLastGoodWorkspace(pw.path); return pw.path };
}
}
if (lastGoodWorkspace) return lastGoodWorkspace;
return null;
} catch { if (lastGoodWorkspace) return lastGoodWorkspace; return null; }
}
// 给 /api/dsh-graph* 请求统一追加 ?workspace=(GET/POST 通用;已知则带,未知则裸路径)
function graphUrl(path, extraParams = {}) {
const p = new URLSearchParams(extraParams);
const ws = currentWorkspace();
if (ws) p.set("workspace", ws);
const qs = p.toString();
if (!qs) return path;
return path + (path.includes("?") ? "&" : "?") + qs;
}
// 跳转后把会话页切回「对话」tab:chat 是 conversation.view 中 order=0 的固定首 tab;
// tab 选中态存在 ui-conversation 的 per-session chatStore 内、无跨插件 API(源码核实),
// 故在跳转后点一下首 tab(仅当当前选中不是它)。无 tab 栏(单视图)时不动。
function activateChatTab() {
requestAnimationFrame(() => requestAnimationFrame(() => {
try {
const tabs = document.querySelectorAll('[role="tablist"] [role="tab"]');
if (tabs.length < 2) return;
const first = tabs[0];
if (first.getAttribute("aria-selected") !== "true") first.click();
} catch { /* 静默 */ }
}));
}
async function openChildSession(parentSessionId, childId) {
const rt = sessionsRt ?? appCtx?.get?.("sessions");
try {
if (!rt) return;
// 目录必须先加载,否则 selectSubagent 抛 "not a healthy catalog child"(发现#21)
rt.setSubagentCatalogOpen?.(parentSessionId, true);
await rt.refreshSubagents?.(parentSessionId);
const entries = rt.list?.getSnapshot?.().subagentsByParent?.[parentSessionId]?.entries ?? [];
const entry = entries.find((e) => e.kind === "child" && e.id === childId);
if (entry) {
rt.openSubagent?.({ parentSessionId, childSessionId: childId, mode: entry.mode });
activateChatTab();
} else {
// 目录里没有(不健康/已清理):退化为打开父会话
console.warn("[dsh-graph-host] child not in catalog, opening parent:", childId);
rt.open?.(parentSessionId);
activateChatTab();
}
} catch (e) {
console.warn("[dsh-graph-host] openSubagent failed", e);
try { rt?.open?.(parentSessionId); activateChatTab(); } catch { /* 静默 */ }
}
}
function sessionLinkBtn(parentSessionId, childId, label) {
if (!childId) return null;
return h("button", {
style: { ...S.btn, fontSize: 11, padding: "0 6px", marginLeft: 6, flexShrink: 0 },
className: "dg-btn",
title: parentSessionId ? "跳转到子代理会话" : "子代理 id(父会话未知,仅展示)",
onClick: (e) => { e.stopPropagation(); if (parentSessionId) openChildSession(parentSessionId, childId); },
}, label ?? "↗ 会话");
}
return {
name: "dsh-graph",
inject: ["slots", "sessions", "connection"],
apply(ctx) {
appCtx = ctx;
sessionsRt = ctx.sessions ?? null;
connectionRt = ctx.connection ?? null;
// workspaces 服务经 ctx.get(name) 可选查找即可取到(runner 的 ctx.get 方法不要求 inject 声明,
// 注入门禁只拦 ctx.workspaces 属性访问;workspaces 由 client-runtime `ctx.reflect.provide` 提供)
workspacesRt = ctx.get?.("workspaces") ?? null;
ctx.slots.inject("conversation.view", () =>
ctx.slots.register(
{ name: "conversation.view", id: "dsh-graph-kanban", order: 80, label: "看板" },
(props) => h(KanbanView, props),
),
);
console.log("[dsh-graph-host] client apply: kanban view registered");
},
};
},
});
// 数据源:sessions.binding(childId).session(uSES 快照 subscribe/getSnapshot),
// 流式行读 chat.legacy.partial(必须先 session.open()),token/上下文走投影
// faceOf("tokenUsage"|"contextPressure")(无需 open),模型走 connection.api.sessions.models,
// 发指令走 session.prompt(continuable 子代理自动路由 api.subagents.prompt,仅文本),
// 最近记录走 connection.api.subagents.history。
const boundSetup = new Map(); // childId -> Promise(open/地址配置只做一次)
const boundModes = new Map(); // childId -> 'one-shot' | 'continuable'
// 子代理地址配置(路由 prompt/history 到 subagents.*)+ 打开尾页以接收活事件。
// 目录 entry 提供真实 mode;目录未收录时跳过地址配置(指令走 session.prompt 默认路由,错误会明示)。
function setupBoundSession(parentId, childId, session) {
if (boundSetup.has(childId)) return boundSetup.get(childId);
const p = (async () => {
if (parentId) {
try {
sessionsRt.setSubagentCatalogOpen?.(parentId, true);
await sessionsRt.refreshSubagents?.(parentId);
const entries = sessionsRt.list?.getSnapshot?.().subagentsByParent?.[parentId]?.entries ?? [];
const entry = entries.find((e) => e.kind === "child" && e.id === childId);
if (entry) {
boundModes.set(childId, entry.mode);
session.configureSubagent?.(
{ parentSessionId: parentId, childSessionId: childId, mode: entry.mode }, true);
}
} catch (e) {
console.warn("[dsh-graph-host] 子代理地址配置失败", e);
}
}
try { await session.open(); } catch (e) {
console.warn("[dsh-graph-host] session.open() 失败", e);
}
})();
boundSetup.set(childId, p);
return p;
}
const NOOP_UNSUB = () => () => {};
// 会话列表快照:列表刷新(含子代理会话入列)时触发重渲染,binding 随之可解析
function useSessionsList() {
const subscribe = React.useCallback(
(cb) => (sessionsRt?.list ? sessionsRt.list.subscribe(cb) : NOOP_UNSUB()), []);
const getSnapshot = React.useCallback(
() => (sessionsRt?.list ? sessionsRt.list.getSnapshot() : null), []);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
// 解析绑定 childId 的 Session(binding 是文档化的纯解析,渲染期安全)
function useBoundSession(parentId, childId) {
const listSnap = useSessionsList();
const session = React.useMemo(() => {
if (!sessionsRt || !childId) return null;
try { return sessionsRt.binding(childId)?.session ?? null; }
catch (e) { console.warn("[dsh-graph-host] binding 解析失败", e); return null; }
}, [childId, listSnap]);
const [mode, setMode] = React.useState(boundModes.get(childId) ?? null);
React.useEffect(() => {
if (!session) return;
let alive = true;
setupBoundSession(parentId, childId, session).then(() => {
if (alive) setMode(boundModes.get(childId) ?? null);
});
return () => { alive = false; };
}, [session, parentId, childId]);
return { session, mode };
}
function useSessionSnapshot(session) {
const subscribe = React.useCallback(
(cb) => (session ? session.subscribe(cb) : NOOP_UNSUB()), [session]);
const getSnapshot = React.useCallback(
() => (session ? session.getSnapshot() : null), [session]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
// 投影值(faceOf 返回 identity-stable 的 uSES face;投影推送不要求 open,看板常驻)
function useProjectionValue(session, key) {
const face = React.useMemo(
() => (session?.projections ? session.projections.faceOf(key) : null), [session, key]);
const subscribe = React.useCallback(
(cb) => (face ? face.subscribe(cb) : NOOP_UNSUB()), [face]);
const getSnapshot = React.useCallback(
() => (face ? face.getSnapshot() : undefined), [face]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
// 流式快照(chat.legacy.partial)的最新一行可读输出
function lastStreamLine(partial) {
const blocks = partial?.blocks ?? [];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if ((b.kind === "text" || b.kind === "reasoning") && b.text) {
const lines = b.text.split("\n").map((s) => s.trim()).filter(Boolean);
if (lines.length) return (b.kind === "reasoning" ? "💭 " : "") + lines[lines.length - 1];
} else if (b.kind === "tool-call" && b.name) {
return "🔧 调用工具 " + b.name;
}
}
return null;
}
function fmtTok(n) {
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
// 状态延续时长(statusAt 距今多久)——g-124 staleStatus 分支显示用(负责人 2026-08-22)
function fmtElapsed(ts, now) {
const ms = now - ts;
if (!(ms > 0)) return "刚刚";
const s = Math.floor(ms / 1000);
if (s < 60) return s + " 秒";
const m = Math.floor(s / 60);
if (m < 60) return m + " 分钟";
const h = Math.floor(m / 60);
if (h < 24) return h + " 小时" + (m % 60 ? " " + (m % 60) + " 分" : "");
const d = Math.floor(h / 24);
return d + " 天" + (h % 24 ? " " + (h % 24) + " 小时" : "");
}
// token/上下文占用的紧凑文本(LiveStrip 与 SessionPanel 折叠态共用)
function liveMeter(usage, pressure) {
const tokTotal = usage
? usage.uncachedInputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
: null;
const ctxPct = pressure && pressure.contextWindow
? Math.round((100 * (pressure.projectedTokens ?? pressure.pressureTokens ?? 0)) / pressure.contextWindow)
: null;
return [
tokTotal !== null ? "tok " + fmtTok(tokTotal) : null,
ctxPct !== null ? "ctx " + ctxPct + "%" : null,
].filter(Boolean).join(" | ");
}
// 卡片内嵌实时条(g-129 负责人 2026-08-22 格式调整):第一行 = 运行状态 + 流式内容(同行,
// 流式时有时无不再引起高度变化);status_line + tok/ctx 放 tooltip(悬浮查看)。
function LiveStrip(props) {
const { session } = useBoundSession(props.parentId, props.childId);
function SupervisorBar(props) {
const { model, modelErr } = useSessionModel(props.id, null);
const jump = () => {
try {
sessionsRt?.open?.(props.id); // supervisor 是顶层会话,直接 open
activateChatTab(); // 已在该会话看板 tab 时切回「对话」
} catch (e) {
console.warn("[dsh-graph-host] 跳转主管会话失败", e);
}
};
return h(
"div",
{ style: S.supervisorBar, className: "dg-supervisor" },
h("span", { style: { fontWeight: 600, flexShrink: 0 } }, "🧭 主管"),
h("div", { style: { flex: 1, minWidth: 0 } },
h(LiveStrip, { parentId: null, childId: props.id, statusLine: props.statusLine ?? null, statusAt: props.statusAt ?? null })),
h("span", { style: { ...S.meta, flexShrink: 0 } },
model ? `${model.provider}/${model.model}` : modelErr ? "模型不可用" : "模型查询中…"),
h("button", {
style: { ...S.btn, flexShrink: 0 }, className: "dg-btn",
title: "跳转到主管 Agent 对话窗", onClick: jump,
}, "↗ 主管对话"),
);
}
// g-a92e1406:被复用徽章——同一 child_id 跨目标绑定时旧绑定显示「被复用→新目标」
function ReusedBadge(props) {
const { childId, reusedBy } = props;
if (!childId || !reusedBy) return null;
return h("div", { style: { ...S.meta, color: "#e0a53a", marginTop: 2 } },
`♻️ 被复用→${reusedBy}`);
}
// g-125:上下文摘要默认折叠到 2 行(截断+省略号),点击展开全文;
// 短摘要(≤40 字)不折叠,直接整行显示。状态提升自 Card(无 hooks 的纯函数)外。
function CardSummary(props) {
+78
-0

@@ -46,2 +46,80 @@ /** 事件流:events.jsonl 是全部状态的唯一真相源(R-02)。 */

/**
* 从事件流重建各版本泳道最终状态:
* version.created → 存活;version.renamed → slug 变更;version.deleted → 删除。
* version.released → 状态 released;version.status_changed → 状态切换(g-135)。
* 返回 Map<slug, { alive, meta }>,其中 alive=false 表示已删除。
*/
export function replayVersionLanes(events) {
const lanes = new Map();
for (const ev of events) {
if (ev.event === "version.created") {
const slug = ev.details?.version;
if (!slug)
continue;
lanes.set(slug, {
alive: true,
meta: {
id: ev.details.version_id ?? null,
name: ev.details.name ?? slug,
status: ev.details.status ?? "planning",
created_at: ev.details.created_at ?? ev.ts,
},
});
}
else if (ev.event === "version.renamed") {
const oldSlug = ev.details?.old_slug;
const newSlug = ev.details?.new_slug;
if (!oldSlug || !newSlug)
continue;
const existing = lanes.get(oldSlug);
if (existing && existing.alive) {
lanes.delete(oldSlug);
lanes.set(newSlug, {
alive: true,
meta: {
...existing.meta,
name: ev.details.new_name ?? existing.meta.name,
},
});
}
}
else if (ev.event === "version.deleted") {
const slug = ev.details?.version;
if (!slug)
continue;
const existing = lanes.get(slug);
if (existing) {
lanes.set(slug, { ...existing, alive: false });
}
}
else if (ev.event === "version.released") {
// g-135:版本发布事件 → 状态设为 released
const slug = ev.details?.version;
if (!slug)
continue;
const existing = lanes.get(slug);
if (existing && existing.alive) {
lanes.set(slug, {
alive: true,
meta: { ...existing.meta, status: "released" },
});
}
}
else if (ev.event === "version.status_changed") {
// g-135:版本状态切换事件(如 working → active)
const slug = ev.details?.version;
if (!slug)
continue;
const existing = lanes.get(slug);
if (existing && existing.alive) {
lanes.set(slug, {
alive: true,
meta: { ...existing.meta, status: ev.details.new_status ?? existing.meta.status },
});
}
}
}
return lanes;
}
/**
* 从事件流重建各目标状态:

@@ -48,0 +126,0 @@ * goal.created → draft(无 version)或 planning(有 version);

+7
-2

@@ -6,3 +6,3 @@ /**

import { GraphError } from "./machine.js";
import { init, createGoal, setCriteria, transition, validate, rebuild, addCard, fillCard, reviewCard, startAttempt, reportStatus, moveGoal, amendGoal, deleteGoal, archiveGoal, unarchiveGoal, } from "./ops.js";
import { init, createGoal, setCriteria, transition, validate, rebuild, addCard, fillCard, reviewCard, startAttempt, reportStatus, moveGoal, amendGoal, deleteGoal, archiveGoal, unarchiveGoal, deleteCard, } from "./ops.js";
function parseArgs(argv) {

@@ -106,2 +106,7 @@ const args = { root: ".dsh-graph", flags: new Map() };

}
case "delete-card": {
deleteCard(args.root, need(args, "goal"), need(args, "card"), { actor });
console.log("ok");
return;
}
case "start-attempt": {

@@ -181,3 +186,3 @@ const attId = startAttempt(args.root, need(args, "goal"), {

default:
throw new GraphError("用法:node core/main.ts [--root DIR] <init|create-goal|set-criteria|transition|add-card|fill-card|review-card|start-attempt|report-status|move-goal|amend-goal|archive-goal|unarchive-goal|delete-goal|validate|rebuild> [flags]");
throw new GraphError("用法:node core/main.ts [--root DIR] <init|create-goal|set-criteria|transition|add-card|fill-card|review-card|delete-card|start-attempt|report-status|move-goal|amend-goal|archive-goal|unarchive-goal|delete-goal|validate|rebuild> [flags]");
}

@@ -184,0 +189,0 @@ }

@@ -13,6 +13,124 @@ /**

* config.root 仍可覆盖(用户层 patch / --patch overlay):绝对路径原样返回,相对路径以 workspace 根为基准。
*
* g-149 扩展:Git linked-worktree canonicalization。
* `resolveCanonicalRoot` 包装 `resolveRoot`,在 config.root 为相对路径时检测 workspace
* 是否处于 Git linked worktree,若是则归一到主工作树的 canonical graph root。
* 显式绝对 config.root 不做 Git 发现(管理员覆盖);Git 发现失败时安全回退到普通 resolveRoot。
*/
import { resolve } from "node:path";
import { resolve, isAbsolute } from "node:path";
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
export function resolveRoot(config, workspaceRoot = process.cwd()) {
return resolve(workspaceRoot, config?.root ?? ".dsh-graph");
}
/**
* Discover Git worktree metadata for a workspace directory.
* Uses `git worktree list --porcelain` whose first entry is always the main worktree.
* Returns null if the workspace is not inside a Git repo, git is unavailable,
* or the command fails for any reason (safe fallback).
*/
export function discoverGitWorktree(workspaceRoot) {
try {
// First, verify this is a Git repo (any kind — main or linked worktree)
execSync("git rev-parse --is-inside-work-tree", {
cwd: workspaceRoot,
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
});
}
catch {
return null; // not a git repo or git unavailable
}
try {
const output = execSync("git worktree list --porcelain", {
cwd: workspaceRoot,
timeout: 5000,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
// Parse the first worktree entry (always the main worktree)
const lines = output.split("\n");
let mainPath = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
mainPath = line.slice("worktree ".length).trim();
break;
}
}
if (!mainPath)
return null;
// Resolve the workspace to an absolute canonical path for comparison
const resolvedWorkspace = resolve(workspaceRoot);
const isLinked = resolvedWorkspace !== resolve(mainPath);
return {
mainWorktree: resolve(mainPath),
workspace: resolvedWorkspace,
isLinkedWorktree: isLinked,
};
}
catch {
return null; // git worktree command failed
}
}
/**
* Canonical graph root resolver (g-149).
*
* Wraps `resolveRoot` with Git linked-worktree detection:
* - Explicit absolute `config.root`: returns as-is, no Git discovery.
* - Relative config.root (default `.dsh-graph`): if workspace is a Git linked worktree,
* canonicalizes to `<main-worktree>/<config.root>`. If discovery fails, falls back to
* normal `resolveRoot` with mode "workspace-fallback".
* - Detects legacy worktree-local `.dsh-graph` directories and attaches warnings.
*
* The `init` function is NOT called here — callers decide whether/where to init.
*/
export function resolveCanonicalRoot(config, workspaceRoot = process.cwd()) {
const rawRoot = config?.root;
const workspace = resolve(workspaceRoot);
// Case 1: explicit absolute config.root — bypass Git discovery entirely
if (rawRoot && isAbsolute(rawRoot)) {
return {
root: resolve(rawRoot),
workspace,
canonicalWorkspace: workspace,
mode: "absolute-config",
};
}
// Case 2: relative config.root (including default ".dsh-graph")
const relRoot = rawRoot ?? ".dsh-graph";
const localRoot = resolve(workspace, relRoot);
// Attempt Git discovery
const gitInfo = discoverGitWorktree(workspace);
if (!gitInfo) {
// Not a Git repo or git unavailable — workspace-local fallback
return {
root: localRoot,
workspace,
canonicalWorkspace: workspace,
mode: "workspace-fallback",
};
}
// If workspace IS the main worktree, normal resolution
if (!gitInfo.isLinkedWorktree) {
return {
root: localRoot,
workspace,
canonicalWorkspace: workspace,
mode: "main-tree",
};
}
// Linked worktree — canonicalize to main worktree
const canonicalRoot = resolve(gitInfo.mainWorktree, relRoot);
let rootWarning;
// Detect legacy worktree-local graph data
if (existsSync(localRoot) && existsSync(resolve(localRoot, "events.jsonl"))) {
rootWarning = `发现 worktree 本地旧看板 ${localRoot};canonical graph root 为 ${canonicalRoot}。旧数据不会自动合并或删除,请手动迁移。`;
}
return {
root: canonicalRoot,
workspace,
canonicalWorkspace: gitInfo.mainWorktree,
mode: "canonicalized",
rootWarning,
};
}
{
"name": "dsh-graph",
"version": "0.5.2",
"version": "0.6.1",
"description": "dsh-graph 单包(host+client 合并,g-116):把 dsh-graph 核心层包装为 DSH cordis 插件,同时提供 graph_* 目标生命周期工具(建卡/判据/迁移/派发/评审/交付)、/api/dsh-graph REST 端点与浏览器二维泳道看板(conversation.view)。包名 = repo 名(负责人定案);内部 host 插件 id 保留 dsh-graph-host。",

@@ -40,7 +40,2 @@ "license": "MIT",

},
"scripts": {
"prepack": "bash ../scripts/sync-core.sh && node -e \"require('node:fs').existsSync('core/ops.js') || (console.error('core 未同步'), process.exit(1))\"",
"build": "bash ../scripts/sync-core.sh",
"test": "node --test ../core/tests/*.test.ts"
},
"dsh": {

@@ -56,3 +51,7 @@ "bundle": {

}
},
"scripts": {
"build": "bash ../scripts/sync-core.sh",
"test": "node --test ../core/tests/*.test.ts"
}
}
}

@@ -56,2 +56,6 @@ # dsh-graph

**g-149 canonical root**:Git linked worktree 自动归一到主工作树的 graph root(`resolveCanonicalRoot`),不会在 code worktree 下 init 第二份。`apply()` 不再以 `process.cwd()` 自动创建骨架——有明确 session/request workspace 或 `sandboxPolicy` 时才 init。详见根 README「独立数据仓库模式」。
**数据 repo 边界**:`.dsh-graph` 可由独立 Git 仓库管理(内层仓库跟踪 `events.jsonl`)。父仓库 `.gitignore` 以 `**/.dsh-graph/` 通配规则防止子目录意外引入。supervisor 和子 Agent 不得用 `git add -f` 把 `.dsh-graph` 数据重新纳入父代码仓库 Git。
## 与 dsh-graph-client 的关系(g-116 合并后)

@@ -58,0 +62,0 @@

@@ -93,10 +93,17 @@ ---

停轮等人工审核;
4. **负责人 verdict**:通过 → `review→delivered`;打回 → `review→in_progress`
并开新 attempt(不沿用失败 attempt)。
4. **负责人 verdict**:通过 → `review→delivered`;较大返工或新范围打回 →
`review→in_progress` 并开新 attempt(不沿用失败 attempt)。
**review 期间一旦子代理收到反馈重新开始改动/修 bug,立即把卡片
`review→in_progress` 放回执行 lane**——看板必须反映"正在改动"的事实,
改完重新声明完成再回 review;小修可续用当前 attempt 会话(缓存友好),
打回重做才开新 attempt;
`review→in_progress` 放回执行 lane**——看板必须反映"正在改动"的事实。
对同一 goal 的小范围 review 缺陷,优先复用原执行 Agent 的既有 attempt 会话,
以 `send_message` 发送精确修复反馈;不必新建 attempt,也不必新增 context card。
此例外仅适用于已有 Agent 的后续返工,不改变新 child 的首次主要任务、边界与
验收必须在 spawn 前进入初始 prompt 的要求;较大返工或新范围仍遵循既有负责人
gate/新 attempt 政策。改完重新声明完成再回 review;打回重做才开新 attempt;
5. 任何阶段受阻 → `→blocked` 必须带具体 reason;解除只能回到 `blocked_from`。
### Review strictness calibration(项目专属)
Review 严格度**不是全局默认值**。首次初始化/接手一个项目时(最迟在首次实质技术复核前),supervisor 应请负责人明确该项目的 review 原则:威胁/信任模型(本地可信或多用户/对抗);必须阻断的类别(判据、正常流程、数据丢失、输入错误);对 legacy/畸形可选数据的防御性兼容要求;所需证据(测试、代码审查、UI smoke、生成工件);并发与崩溃恢复预期;以及集成/合并纪律。将负责人的回答写入该项目持久的 goal/supervisor memory,并在后续 review 中据此执行;**不得把本项目的选择硬编码为通用规则**。
6. **交付前置(负责人 2026-08-22 定,2026-08-23 细化 commit 归属):到 delivered 的目标,其改动必须已 git commit**——

@@ -225,9 +232,12 @@ 但**区分谁、何时 commit**:

(graph_* 工具写的是主工作树的看板/事件流,不被 worktree 分支隔离);
- **只在仓库根跑 graph_* 工具(负责人 2026-08-22)**:执行/调研子代理务必以
- **只在仓库根跑 graph_* 工具(负责人 2026-08-22;g-149 修订)**:执行/调研子代理务必以
**仓库根**为工作目录跑 graph_* 工具,**绝不在包目录(如 `dsh-graph-host/`)下跑**——
否则工具会按会话 cwd 在包目录自动 init 出一个 `.dsh-graph/` 骨架(「子代理误建数据
目录」的已知问题,曾多次清理)。该目录非项目数据,已 gitignore
(`dsh-graph-host/.dsh-graph/`)防 git 污染,但会弄乱工作区——子代理应统一在
仓库根的 `.dsh-graph/` 读写看板数据;supervisor 派发时若发现子代理 cwd 落在包目录,
及时纠正。
目录」的已知问题,曾多次清理)。父仓库 `.gitignore` 以 `**/.dsh-graph/` 通配规则
防止任何子目录的 `.dsh-graph` 被 `git add -A` 收集(覆盖包目录、子 Agent cwd、
linked worktree 等所有场景),但会弄乱工作区——子代理应统一在仓库根的 `.dsh-graph/`
读写看板数据;supervisor 派发时若发现子代理 cwd 落在包目录,及时纠正。
**禁止** supervisor 或子 Agent 使用 `git add -f`、`git rm --cached` 等方式把
`.dsh-graph` 数据重新纳入父代码仓库 Git——数据归内层独立仓库管理,迁移由
`scripts/migrate-dsh-graph-repo.sh --apply` 显式执行。

@@ -234,0 +244,0 @@ - **模型路由**:执行子代理**不继承父会话模型**——统一走 project.yaml 的

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display