@joekytc/dsh-swarm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/README.zh-CN.md +452 -0
- package/client/BoardCard.tsx +41 -0
- package/client/ConnectionBanner.tsx +16 -0
- package/client/KanbanBoard.tsx +168 -0
- package/client/KanbanTab.tsx +26 -0
- package/client/RenameModal.tsx +38 -0
- package/client/TaskDrawer.tsx +212 -0
- package/client/WorkflowRail.tsx +175 -0
- package/client/board-store.ts +196 -0
- package/client/css.d.ts +4 -0
- package/client/index.ts +31 -0
- package/client/kanban.css +653 -0
- package/client/useKanbanBoard.ts +7 -0
- package/client/workflow-model.ts +229 -0
- package/cordis.patch.yml +88 -0
- package/lib/client.js +1205 -0
- package/lib/config.d.ts +46 -0
- package/lib/config.js +43 -0
- package/lib/dispatcher/agent-runner.d.ts +23 -0
- package/lib/dispatcher/agent-runner.js +429 -0
- package/lib/dispatcher/chain-auditor.d.ts +47 -0
- package/lib/dispatcher/chain-auditor.js +194 -0
- package/lib/dispatcher/dispatcher.d.ts +50 -0
- package/lib/dispatcher/dispatcher.js +280 -0
- package/lib/dispatcher/event-waker.d.ts +13 -0
- package/lib/dispatcher/event-waker.js +24 -0
- package/lib/dispatcher/git-credentials.d.ts +33 -0
- package/lib/dispatcher/git-credentials.js +78 -0
- package/lib/dispatcher/merge-gate.d.ts +28 -0
- package/lib/dispatcher/merge-gate.js +74 -0
- package/lib/dispatcher/model-candidates.d.ts +13 -0
- package/lib/dispatcher/model-candidates.js +31 -0
- package/lib/dispatcher/session-events.d.ts +28 -0
- package/lib/dispatcher/session-events.js +33 -0
- package/lib/dispatcher/target-repo.d.ts +15 -0
- package/lib/dispatcher/target-repo.js +42 -0
- package/lib/dispatcher/v-orchestrator.d.ts +74 -0
- package/lib/dispatcher/v-orchestrator.js +452 -0
- package/lib/dispatcher/watchdog.d.ts +15 -0
- package/lib/dispatcher/watchdog.js +30 -0
- package/lib/dispatcher/workspace-attach.d.ts +40 -0
- package/lib/dispatcher/workspace-attach.js +112 -0
- package/lib/domain/delivery-contract.d.ts +18 -0
- package/lib/domain/delivery-contract.js +80 -0
- package/lib/domain/delivery-evidence.d.ts +12 -0
- package/lib/domain/delivery-evidence.js +41 -0
- package/lib/domain/event-store.d.ts +20 -0
- package/lib/domain/event-store.js +53 -0
- package/lib/domain/kanban-service.d.ts +94 -0
- package/lib/domain/kanban-service.js +430 -0
- package/lib/domain/permissions.d.ts +7 -0
- package/lib/domain/permissions.js +54 -0
- package/lib/domain/planning-checklist.d.ts +22 -0
- package/lib/domain/planning-checklist.js +81 -0
- package/lib/domain/prefetch-manifest.d.ts +21 -0
- package/lib/domain/prefetch-manifest.js +73 -0
- package/lib/domain/projection.d.ts +3 -0
- package/lib/domain/projection.js +165 -0
- package/lib/domain/review-evidence.d.ts +13 -0
- package/lib/domain/review-evidence.js +76 -0
- package/lib/domain/state-machine.d.ts +4 -0
- package/lib/domain/state-machine.js +32 -0
- package/lib/domain/task-parents.d.ts +17 -0
- package/lib/domain/task-parents.js +39 -0
- package/lib/domain/tdd-classify.d.ts +5 -0
- package/lib/domain/tdd-classify.js +20 -0
- package/lib/domain/types.d.ts +142 -0
- package/lib/domain/types.js +2 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +56 -0
- package/lib/roles/preset-installer.d.ts +8 -0
- package/lib/roles/preset-installer.js +53 -0
- package/lib/roles/toolsets.d.ts +62 -0
- package/lib/roles/toolsets.js +332 -0
- package/lib/roles/wiki-worker.d.ts +20 -0
- package/lib/roles/wiki-worker.js +44 -0
- package/lib/routes/kanban-http.d.ts +6 -0
- package/lib/routes/kanban-http.js +159 -0
- package/lib/routes/kanban-sse.d.ts +8 -0
- package/lib/routes/kanban-sse.js +58 -0
- package/lib/routes/planning-driver.d.ts +16 -0
- package/lib/routes/planning-driver.js +52 -0
- package/lib/routes/prefix-router.d.ts +29 -0
- package/lib/routes/prefix-router.js +28 -0
- package/lib/services/kanban-provider.d.ts +16 -0
- package/lib/services/kanban-provider.js +14 -0
- package/lib/tools/kanban-tools.d.ts +11 -0
- package/lib/tools/kanban-tools.js +169 -0
- package/lib/tools/main-session-tools.d.ts +20 -0
- package/lib/tools/main-session-tools.js +170 -0
- package/lib/tools/planning-tools.d.ts +32 -0
- package/lib/tools/planning-tools.js +102 -0
- package/lib/tools/prefetch-tools.d.ts +5 -0
- package/lib/tools/prefetch-tools.js +59 -0
- package/lib/tools/spec-card-tools.d.ts +4 -0
- package/lib/tools/spec-card-tools.js +75 -0
- package/lib/tools/wiki-tools.d.ts +4 -0
- package/lib/tools/wiki-tools.js +57 -0
- package/lib/wiki/kb-linkage.d.ts +9 -0
- package/lib/wiki/kb-linkage.js +87 -0
- package/lib/wiki/page-path.d.ts +6 -0
- package/lib/wiki/page-path.js +28 -0
- package/lib/wiki/wiki-vault-client.d.ts +28 -0
- package/lib/wiki/wiki-vault-client.js +48 -0
- package/package.json +83 -0
- package/personas/kanban-d/agent.cordis.yml +157 -0
- package/personas/kanban-d/preset.yml +2 -0
- package/personas/kanban-dt/agent.cordis.yml +71 -0
- package/personas/kanban-dt/preset.yml +2 -0
- package/personas/kanban-p/agent.cordis.yml +66 -0
- package/personas/kanban-p/preset.yml +2 -0
- package/personas/kanban-pt/agent.cordis.yml +47 -0
- package/personas/kanban-pt/preset.yml +2 -0
- package/personas/kanban-v/agent.cordis.yml +47 -0
- package/personas/kanban-v/preset.yml +2 -0
- package/personas/kanban-w/agent.cordis.yml +47 -0
- package/personas/kanban-w/preset.yml +2 -0
- package/personas/persona-d.md +26 -0
- package/personas/persona-dt.md +18 -0
- package/personas/persona-p.md +13 -0
- package/personas/persona-pt.md +13 -0
- package/personas/persona-v.md +18 -0
- package/personas/persona-w.md +12 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { BoardState, Handoff, Task } from '../src/domain/types.js';
|
|
3
|
+
import type { BoardClientSnapshot } from './board-store.js';
|
|
4
|
+
import { deriveWorkflowBoard, type ChainFilter } from './workflow-model.js';
|
|
5
|
+
import { WorkflowRail } from './WorkflowRail.js';
|
|
6
|
+
import { TaskDrawer } from './TaskDrawer.js';
|
|
7
|
+
|
|
8
|
+
/** 折叠面板默认全打开(用户决策):只记录用户手动折叠的链路(collapsed 集合),未折叠即展开。 */
|
|
9
|
+
function defaultCollapsed(): Set<string> { return new Set(); }
|
|
10
|
+
|
|
11
|
+
/** T26/T27/T32:列表(多链路垂直轨道)↔ 原位任务详情切换;归档筛选、详情未读提示、乐观操作失败回滚后的可重试错误。
|
|
12
|
+
* 折叠面板默认全打开:collapsed 集合只含用户手动折叠的链路,新链路默认展开。 */
|
|
13
|
+
export function KanbanBoard(props: {
|
|
14
|
+
snapshot: BoardClientSnapshot;
|
|
15
|
+
postAction(action: unknown): Promise<unknown>;
|
|
16
|
+
/** purge 类操作无事件流,成功后需重拉权威快照。 */
|
|
17
|
+
onResync?(): Promise<void>;
|
|
18
|
+
}) {
|
|
19
|
+
const { board } = props.snapshot;
|
|
20
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => defaultCollapsed());
|
|
21
|
+
const [query, setQuery] = useState('');
|
|
22
|
+
const [statusFilter, setStatusFilter] = useState<Set<ChainFilter>>(new Set());
|
|
23
|
+
const [selectedId, setSelectedId] = useState<string | null>(() => history.state?.kanbanTaskId ?? null);
|
|
24
|
+
const [failedAction, setFailedAction] = useState<{ taskId: string; action: unknown } | null>(null);
|
|
25
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const saved = useRef<{ collapsed: string[]; scrollTop: number }>({ collapsed: [], scrollTop: 0 });
|
|
27
|
+
const snapshotRef = useRef(props.snapshot);
|
|
28
|
+
snapshotRef.current = props.snapshot;
|
|
29
|
+
const detailOpenedSeq = useRef<number | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const onPop = () => {
|
|
33
|
+
const id = history.state?.kanbanTaskId ?? null;
|
|
34
|
+
detailOpenedSeq.current = id ? snapshotRef.current.lastSeq : null;
|
|
35
|
+
setSelectedId(id);
|
|
36
|
+
if (!id) {
|
|
37
|
+
setCollapsed(new Set(saved.current.collapsed));
|
|
38
|
+
requestAnimationFrame(() => { if (listRef.current) listRef.current.scrollTop = saved.current.scrollTop; });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener('popstate', onPop);
|
|
42
|
+
return () => window.removeEventListener('popstate', onPop);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const onKey = (e: KeyboardEvent) => {
|
|
47
|
+
if (e.key === 'Escape' && selectedId) history.back();
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('keydown', onKey);
|
|
50
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
51
|
+
}, [selectedId]);
|
|
52
|
+
|
|
53
|
+
const views = useMemo(
|
|
54
|
+
() => (board ? deriveWorkflowBoard(board, { selectedTaskId: selectedId, now: Date.now(), statusFilter }) : []),
|
|
55
|
+
[board, selectedId, statusFilter],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const runAction = async (action: unknown) => {
|
|
59
|
+
try {
|
|
60
|
+
await props.postAction(action);
|
|
61
|
+
} catch {
|
|
62
|
+
const a = action as { taskId?: string };
|
|
63
|
+
setFailedAction({ taskId: a.taskId ?? '', action });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** 状态筛选:多选并集;再次点击取消选中。 */
|
|
68
|
+
const toggleFilter = (f: ChainFilter) => {
|
|
69
|
+
setStatusFilter((current) => {
|
|
70
|
+
const next = new Set(current);
|
|
71
|
+
if (next.has(f)) next.delete(f); else next.add(f);
|
|
72
|
+
return next;
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** 折叠面板:点击链路标题切换折叠/展开(默认全打开,collapsed 记录手动折叠的链路)。 */
|
|
77
|
+
const toggleChain = (chainId: string) => {
|
|
78
|
+
setCollapsed((current) => {
|
|
79
|
+
const next = new Set(current);
|
|
80
|
+
if (next.has(chainId)) next.delete(chainId); else next.add(chainId);
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (!board) {
|
|
86
|
+
return <div className="dsh-kb-loading">加载看板…</div>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const selectedView = selectedId
|
|
90
|
+
? views.flatMap((v) => v.tasks).find((v) => v.task.id === selectedId) ?? null
|
|
91
|
+
: null;
|
|
92
|
+
|
|
93
|
+
if (selectedView) {
|
|
94
|
+
const task: Task = selectedView.task;
|
|
95
|
+
const chain = board.chains.get(task.chainId)!;
|
|
96
|
+
const specCard = chain.specCardId ? board.specCards.get(chain.specCardId) ?? null : null;
|
|
97
|
+
const handoff = board.handoffs.get(task.id) ?? null;
|
|
98
|
+
const parentHandoffs: Handoff[] = task.parents
|
|
99
|
+
.map((id) => board.handoffs.get(id))
|
|
100
|
+
.filter((h): h is Handoff => h !== undefined);
|
|
101
|
+
const parentTasks: Task[] = task.parents
|
|
102
|
+
.map((id) => board.tasks.get(id))
|
|
103
|
+
.filter((t): t is Task => t !== undefined);
|
|
104
|
+
const chainTasks = views.find((v) => v.chain.id === task.chainId)?.tasks ?? [];
|
|
105
|
+
const selectedIndex = chainTasks.findIndex((v) => v.task.id === task.id);
|
|
106
|
+
const related = chainTasks.filter((v) => v.related).map((v) => v.task);
|
|
107
|
+
const upstream = related.filter((t) => chainTasks.findIndex((v) => v.task.id === t.id) < selectedIndex);
|
|
108
|
+
const downstream = related.filter((t) => chainTasks.findIndex((v) => v.task.id === t.id) > selectedIndex);
|
|
109
|
+
// T32:详情打开后到达的该任务新事件 → 头部未读提示,不自动切换 tab/列表
|
|
110
|
+
const unreadCount = board.events.filter((e) => e.taskId === task.id && e.seq > (detailOpenedSeq.current ?? props.snapshot.lastSeq)).length;
|
|
111
|
+
return (
|
|
112
|
+
<TaskDrawer
|
|
113
|
+
task={task}
|
|
114
|
+
chain={chain}
|
|
115
|
+
events={board.events}
|
|
116
|
+
handoff={handoff}
|
|
117
|
+
parentHandoffs={parentHandoffs}
|
|
118
|
+
parentTasks={parentTasks}
|
|
119
|
+
specCard={specCard}
|
|
120
|
+
upstream={upstream}
|
|
121
|
+
downstream={downstream}
|
|
122
|
+
unreadCount={unreadCount}
|
|
123
|
+
actionError={props.snapshot.actionError}
|
|
124
|
+
readOnly={task.status === 'archived'}
|
|
125
|
+
onRetry={failedAction && failedAction.taskId === task.id ? () => void runAction(failedAction.action) : undefined}
|
|
126
|
+
onComment={(body) => void runAction({ type: 'comment', taskId: task.id, body })}
|
|
127
|
+
onAction={(action) => void runAction(action)}
|
|
128
|
+
onClose={() => history.back()}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const openTask = (taskId: string) => {
|
|
134
|
+
detailOpenedSeq.current = props.snapshot.lastSeq;
|
|
135
|
+
saved.current = { collapsed: [...collapsed], scrollTop: listRef.current?.scrollTop ?? 0 };
|
|
136
|
+
history.pushState({ ...history.state, kanbanTaskId: taskId }, '');
|
|
137
|
+
setSelectedId(taskId);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="dsh-kb-tab-body" ref={listRef}>
|
|
142
|
+
<div className="dsh-kb-toolbar">
|
|
143
|
+
<input
|
|
144
|
+
aria-label="搜索链路"
|
|
145
|
+
value={query}
|
|
146
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
147
|
+
placeholder="搜索链路/任务"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
<WorkflowRail
|
|
151
|
+
chains={views}
|
|
152
|
+
collapsedChainIds={collapsed}
|
|
153
|
+
query={query}
|
|
154
|
+
statusFilter={statusFilter}
|
|
155
|
+
onToggleFilter={toggleFilter}
|
|
156
|
+
onToggleChain={toggleChain}
|
|
157
|
+
onOpenTask={openTask}
|
|
158
|
+
onConfirmAudit={(chainId) => void runAction({ type: 'confirm-audit', chainId })}
|
|
159
|
+
onRenameChain={(chainId, title) => void runAction({ type: 'rename', chainId, title })}
|
|
160
|
+
onDeleteChain={async (chainId) => {
|
|
161
|
+
// 直连 postAction:失败 throw 上抛删除弹窗展示(runAction 吞错仅服务 TaskDrawer 重试条)
|
|
162
|
+
await props.postAction({ type: 'delete', chainId });
|
|
163
|
+
await props.onResync?.();
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import { createBoardStore, type BoardStore } from './board-store.js';
|
|
3
|
+
import { useKanbanBoard } from './useKanbanBoard.js';
|
|
4
|
+
import { KanbanBoard } from './KanbanBoard.js';
|
|
5
|
+
import { ConnectionBanner } from './ConnectionBanner.js';
|
|
6
|
+
|
|
7
|
+
/** T30:会话中心 conversation.view 第三个 tab(对话→轨迹→看板)。全高布局,无浮层/拖拽/宽度记忆。 */
|
|
8
|
+
export function KanbanTab(props: { store?: BoardStore } = {}) {
|
|
9
|
+
const own = useMemo(() => props.store ?? createBoardStore(), [props.store]);
|
|
10
|
+
const snapshot = useKanbanBoard(own);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (props.store) return;
|
|
14
|
+
void own.start();
|
|
15
|
+
return () => { own.stop(); };
|
|
16
|
+
}, [own, props.store]);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="dsh-kb-tab" role="region" aria-label="看板">
|
|
20
|
+
<ConnectionBanner connection={snapshot.connection} lastSuccessAt={snapshot.lastSuccessAt} onRetry={() => void own.retry()} />
|
|
21
|
+
{snapshot.board
|
|
22
|
+
? <KanbanBoard snapshot={snapshot} postAction={(action) => own.postAction(action)} onResync={() => own.retry()} />
|
|
23
|
+
: <div className="dsh-kb-loading">加载看板…</div>}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** T7:轻量改名弹窗(链标题/任务卡标题共用;GUI 仅 human)。内联实现,无额外依赖。 */
|
|
4
|
+
export function RenameModal(props: { title: string; initialValue: string; onSave(title: string): void; onCancel(): void }) {
|
|
5
|
+
const [value, setValue] = useState(props.initialValue);
|
|
6
|
+
const save = () => {
|
|
7
|
+
const next = value.trim();
|
|
8
|
+
if (next) props.onSave(next);
|
|
9
|
+
};
|
|
10
|
+
return (
|
|
11
|
+
<div className="dsh-kb-rename-overlay" onClick={(e) => { e.stopPropagation(); props.onCancel(); }}>
|
|
12
|
+
<div
|
|
13
|
+
className="dsh-kb-rename-modal"
|
|
14
|
+
role="dialog"
|
|
15
|
+
aria-modal="true"
|
|
16
|
+
aria-label={props.title}
|
|
17
|
+
onClick={(e) => e.stopPropagation()}
|
|
18
|
+
>
|
|
19
|
+
<div className="dsh-kb-rename-modal__label">{props.title}</div>
|
|
20
|
+
<input
|
|
21
|
+
className="dsh-kb-rename-modal__input"
|
|
22
|
+
aria-label={props.title}
|
|
23
|
+
value={value}
|
|
24
|
+
autoFocus
|
|
25
|
+
onChange={(e) => setValue(e.target.value)}
|
|
26
|
+
onKeyDown={(e) => {
|
|
27
|
+
if (e.key === 'Enter') save();
|
|
28
|
+
if (e.key === 'Escape') props.onCancel();
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
<div className="dsh-kb-rename-modal__actions">
|
|
32
|
+
<button type="button" className="dsh-kb-rename-cancel" onClick={props.onCancel}>取消</button>
|
|
33
|
+
<button type="button" className="dsh-kb-rename-save" onClick={save}>保存</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Chain, Handoff, KanbanEvent, SpecCard, Task } from '../src/domain/types.js';
|
|
3
|
+
import { statusLabelOf } from './workflow-model.js';
|
|
4
|
+
|
|
5
|
+
const ROLE_NAME: Record<Task['assignee'], string> = {
|
|
6
|
+
v: 'orchestrator', p: 'planner', w: 'wiki-bridge', d: 'fullstack-dev', pt: 'plan-review', dt: 'impl-review',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const TABS = [
|
|
10
|
+
['overview', '概览'], ['timeline', '轨迹'], ['handoff', '交接'], ['spec', '规格'], ['comments', '评论'],
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
function formatValue(value: unknown): string {
|
|
14
|
+
if (Array.isArray(value)) return value.map(String).join(', ');
|
|
15
|
+
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
|
|
16
|
+
return String(value ?? '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatTime(at: number): string {
|
|
20
|
+
return new Date(at).toLocaleString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** T27/T32:原位任务详情,固定五区;未读提示、乐观操作失败重试、破坏性操作二次确认。 */
|
|
24
|
+
export function TaskDrawer(props: {
|
|
25
|
+
task: Task;
|
|
26
|
+
chain: Chain;
|
|
27
|
+
events: KanbanEvent[];
|
|
28
|
+
handoff: Handoff | null;
|
|
29
|
+
parentHandoffs: Handoff[];
|
|
30
|
+
parentTasks?: Task[];
|
|
31
|
+
specCard: SpecCard | null;
|
|
32
|
+
upstream: Task[];
|
|
33
|
+
downstream: Task[];
|
|
34
|
+
unreadCount?: number;
|
|
35
|
+
actionError?: { taskId: string; message: string } | null;
|
|
36
|
+
readOnly?: boolean;
|
|
37
|
+
onRetry?: () => void;
|
|
38
|
+
onComment(body: string): void;
|
|
39
|
+
onAction(action: { type: string; taskId: string; reason?: string; summary?: string; metadata?: Record<string, unknown>; body?: string }): void;
|
|
40
|
+
onClose(): void;
|
|
41
|
+
}) {
|
|
42
|
+
const { task, events, handoff, specCard, chain } = props;
|
|
43
|
+
const [tab, setTab] = useState<string>('overview');
|
|
44
|
+
const [pending, setPending] = useState<{ kind: 'block' | 'complete' | 'archive'; value: string } | null>(null);
|
|
45
|
+
const timeline = events.filter((e) => e.taskId === task.id).toSorted((a, b) => a.seq - b.seq);
|
|
46
|
+
const comments = timeline.filter((e) => e.kind === 'task/commented');
|
|
47
|
+
const submitComment = (el: HTMLInputElement) => {
|
|
48
|
+
const value = el.value.trim();
|
|
49
|
+
if (!value) return;
|
|
50
|
+
props.onComment(value);
|
|
51
|
+
el.value = '';
|
|
52
|
+
};
|
|
53
|
+
const arm = (kind: 'block' | 'complete' | 'archive') => {
|
|
54
|
+
setPending({ kind, value: '' });
|
|
55
|
+
if (kind === 'archive') {
|
|
56
|
+
window.setTimeout(() => setPending((current) => (current?.kind === 'archive' ? null : current)), 3000);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// T32 fix:complete/block 服务端强制要求 summary/reason,在确认态内联收集,避免 400 后永久失败
|
|
60
|
+
const submitPayload = (kind: 'block' | 'complete') => {
|
|
61
|
+
if (pending?.kind !== kind || !pending.value.trim()) return;
|
|
62
|
+
const value = pending.value;
|
|
63
|
+
setPending(null);
|
|
64
|
+
if (kind === 'block') props.onAction({ type: 'block', taskId: task.id, reason: value });
|
|
65
|
+
else props.onAction({ type: 'complete', taskId: task.id, summary: value });
|
|
66
|
+
};
|
|
67
|
+
const submitArchive = () => {
|
|
68
|
+
if (pending?.kind !== 'archive') return;
|
|
69
|
+
setPending(null);
|
|
70
|
+
props.onAction({ type: 'archive', taskId: task.id });
|
|
71
|
+
};
|
|
72
|
+
return (
|
|
73
|
+
<div className="dsh-kb-detail">
|
|
74
|
+
<header className="dsh-kb-detail__header">
|
|
75
|
+
<button type="button" aria-label="返回任务列表" onClick={props.onClose}>←</button>
|
|
76
|
+
<span className={`dsh-kb-profile dsh-kb-profile--${task.assignee}`}>{task.assignee.toUpperCase()}</span>
|
|
77
|
+
<div className="dsh-kb-detail__identity">
|
|
78
|
+
<strong>{task.title}</strong>
|
|
79
|
+
<span>{task.id} · {task.mode} · attempt {task.attempts + 1}</span>
|
|
80
|
+
</div>
|
|
81
|
+
{props.unreadCount ? (
|
|
82
|
+
<button type="button" className="dsh-kb-unread" onClick={() => setTab('timeline')}>{props.unreadCount} 条新更新</button>
|
|
83
|
+
) : null}
|
|
84
|
+
{!props.readOnly && (
|
|
85
|
+
<div className="dsh-kb-detail__actions">
|
|
86
|
+
{task.status === 'running' && <button type="button" onClick={() => arm('complete')}>完成</button>}
|
|
87
|
+
{task.status === 'running' && <button type="button" onClick={() => arm('block')}>阻塞</button>}
|
|
88
|
+
{task.status === 'blocked' && <button type="button" onClick={() => props.onAction({ type: 'unblock', taskId: task.id })}>解除阻塞</button>}
|
|
89
|
+
{task.status === 'failed' && <button type="button" onClick={() => props.onAction({ type: 'retry', taskId: task.id })}>重试</button>}
|
|
90
|
+
{['done', 'failed', 'blocked'].includes(task.status) && (
|
|
91
|
+
<button type="button" data-confirming={pending?.kind === 'archive' || undefined} onClick={pending?.kind === 'archive' ? submitArchive : () => arm('archive')}>
|
|
92
|
+
{pending?.kind === 'archive' ? '确认归档' : '归档'}
|
|
93
|
+
</button>
|
|
94
|
+
)}
|
|
95
|
+
{pending?.kind === 'complete' && (
|
|
96
|
+
<span className="dsh-kb-action-form">
|
|
97
|
+
<input aria-label="交接摘要" value={pending.value} onChange={(e) => setPending({ kind: 'complete', value: e.target.value })} placeholder="交接摘要" />
|
|
98
|
+
<button type="button" disabled={!pending.value.trim()} onClick={() => submitPayload('complete')}>确认完成</button>
|
|
99
|
+
<button type="button" onClick={() => setPending(null)}>取消</button>
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
{pending?.kind === 'block' && (
|
|
103
|
+
<span className="dsh-kb-action-form">
|
|
104
|
+
<input aria-label="阻塞原因" value={pending.value} onChange={(e) => setPending({ kind: 'block', value: e.target.value })} placeholder="阻塞原因" />
|
|
105
|
+
<button type="button" disabled={!pending.value.trim()} onClick={() => submitPayload('block')}>确认阻塞</button>
|
|
106
|
+
<button type="button" onClick={() => setPending(null)}>取消</button>
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</header>
|
|
112
|
+
{props.actionError?.taskId === task.id && (
|
|
113
|
+
<div className="dsh-kb-action-error" role="alert">
|
|
114
|
+
<span>操作失败:{props.actionError.message}</span>
|
|
115
|
+
{props.onRetry && <button type="button" onClick={props.onRetry}>重试操作</button>}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
<div role="tablist" aria-label="任务详情">
|
|
119
|
+
{TABS.map(([id, label]) => (
|
|
120
|
+
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>{label}</button>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
{tab === 'overview' && (
|
|
124
|
+
<section role="tabpanel">
|
|
125
|
+
<h4>Workflow 上下文</h4>
|
|
126
|
+
<p>{props.upstream.at(-1)?.title ?? '无上游'} → {task.title} → {props.downstream[0]?.title ?? '无下游'}</p>
|
|
127
|
+
<dl>
|
|
128
|
+
<dt>Profile</dt><dd>{task.assignee.toUpperCase()} / {ROLE_NAME[task.assignee]}</dd>
|
|
129
|
+
<dt>状态</dt><dd>{statusLabelOf(task.status)}</dd>
|
|
130
|
+
<dt>Chain</dt><dd>{chain.title}</dd>
|
|
131
|
+
<dt>优先级</dt><dd>{task.priority}</dd>
|
|
132
|
+
<dt>心跳</dt><dd>{task.heartbeats.length}</dd>
|
|
133
|
+
<dt>依赖</dt><dd>{props.parentTasks && props.parentTasks.length > 0 ? props.parentTasks.map((p) => p.title).join(' → ') : '无'}</dd>
|
|
134
|
+
<dt>重试</dt><dd>{task.attempts} 次{task.status === 'failed' ? ' · 可立即重试' : task.status === 'blocked' ? ' · 解除阻塞后可重试' : ''}</dd>
|
|
135
|
+
</dl>
|
|
136
|
+
<p>{task.body || '无附加任务描述'}</p>
|
|
137
|
+
</section>
|
|
138
|
+
)}
|
|
139
|
+
{tab === 'timeline' && (
|
|
140
|
+
<section role="tabpanel">
|
|
141
|
+
<ol>
|
|
142
|
+
{timeline.map((event) => (
|
|
143
|
+
<li key={event.seq}><code>{event.kind}</code><span>seq {event.seq} · {event.author} · {event.at}</span></li>
|
|
144
|
+
))}
|
|
145
|
+
</ol>
|
|
146
|
+
</section>
|
|
147
|
+
)}
|
|
148
|
+
{tab === 'handoff' && (
|
|
149
|
+
<section role="tabpanel">
|
|
150
|
+
{props.parentTasks && props.parentTasks.length > 0 && (
|
|
151
|
+
<>
|
|
152
|
+
<h4>父任务原文</h4>
|
|
153
|
+
{props.parentTasks.map((p) => (
|
|
154
|
+
<p key={p.id}><strong>{p.title}</strong>{p.body ? `:${p.body}` : '(无正文)'}</p>
|
|
155
|
+
))}
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
{props.parentHandoffs.length > 0 && (
|
|
159
|
+
<>
|
|
160
|
+
<h4>父任务交接</h4>
|
|
161
|
+
{props.parentHandoffs.map((h, i) => (
|
|
162
|
+
<p key={i}><strong>{h.summary}</strong> {formatValue(h.metadata)}</p>
|
|
163
|
+
))}
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
<h4>当前任务交接</h4>
|
|
167
|
+
<p>{handoff?.summary ?? '当前任务尚无交接'}</p>
|
|
168
|
+
{handoff && Object.entries(handoff.metadata).map(([key, value]) => (
|
|
169
|
+
<p key={key}><strong>{key}</strong>: {formatValue(value)}</p>
|
|
170
|
+
))}
|
|
171
|
+
</section>
|
|
172
|
+
)}
|
|
173
|
+
{tab === 'spec' && (
|
|
174
|
+
<section role="tabpanel">
|
|
175
|
+
<h4>Problem</h4><p>{specCard?.sections.problem ?? '无规格卡'}</p>
|
|
176
|
+
<h4>Solution</h4><p>{specCard?.sections.solution ?? '无'}</p>
|
|
177
|
+
<h4>User stories</h4><p>{specCard?.sections.user_stories.join('; ') || '无'}</p>
|
|
178
|
+
<h4>Implementation decisions</h4><p>{specCard?.sections.impl_decisions.join('; ') || '无'}</p>
|
|
179
|
+
<h4>Testing</h4><p>{specCard?.sections.testing ?? '无'}</p>
|
|
180
|
+
<h4>Out of scope</h4><p>{specCard?.sections.out_of_scope ?? '无'}</p>
|
|
181
|
+
<h4>附件</h4>
|
|
182
|
+
{specCard && specCard.attachments.length > 0 ? (
|
|
183
|
+
<ul className="dsh-kb-spec-attachments">
|
|
184
|
+
{specCard.attachments.map((a) => (
|
|
185
|
+
<li key={`${a.name}-${a.ref}`}>
|
|
186
|
+
<span className="dsh-kb-spec-attachment__kind">{a.kind}</span>
|
|
187
|
+
<strong>{a.name}</strong> <code>{a.ref}</code>
|
|
188
|
+
</li>
|
|
189
|
+
))}
|
|
190
|
+
</ul>
|
|
191
|
+
) : <p>无附件</p>}
|
|
192
|
+
</section>
|
|
193
|
+
)}
|
|
194
|
+
{tab === 'comments' && (
|
|
195
|
+
<section role="tabpanel">
|
|
196
|
+
{comments.map((event) => (
|
|
197
|
+
<p key={event.seq}>
|
|
198
|
+
<strong>{event.author}</strong> <time dateTime={new Date(event.at).toISOString()}>{formatTime(event.at)}</time>
|
|
199
|
+
: {String(event.payload['body'])}
|
|
200
|
+
</p>
|
|
201
|
+
))}
|
|
202
|
+
{!props.readOnly && (
|
|
203
|
+
<input
|
|
204
|
+
aria-label="添加评论"
|
|
205
|
+
onKeyDown={(e) => { if (e.key === 'Enter') submitComment(e.target as HTMLInputElement); }}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</section>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { CHAIN_FILTERS, CHAIN_FILTER_LABEL, type ChainFilter, type ChainWorkflowView } from './workflow-model.js';
|
|
3
|
+
import { BoardCard } from './BoardCard.js';
|
|
4
|
+
import { RenameModal } from './RenameModal.js';
|
|
5
|
+
|
|
6
|
+
function matches(view: ChainWorkflowView, query: string): boolean {
|
|
7
|
+
const q = query.trim().toLowerCase();
|
|
8
|
+
if (!q) return true;
|
|
9
|
+
if (view.chain.title.toLowerCase().includes(q)) return true;
|
|
10
|
+
return view.tasks.some((item) => item.task.title.toLowerCase().includes(q));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** T26/T32:多链路垂直轨道。折叠面板默认全打开(collapsed 集合只含手动折叠链路);阻塞链路折叠时仍露出警告摘要;搜索命中期间临时展开;“已完成”筛选切换归档视图。
|
|
14
|
+
* T7:链标题行右侧铅笔改名(div role=button 内嵌按钮,避免 button-in-button)。 */
|
|
15
|
+
export function WorkflowRail(props: {
|
|
16
|
+
chains: ChainWorkflowView[];
|
|
17
|
+
/** 用户手动折叠的链路集合;不在集合内即展开(默认全打开)。 */
|
|
18
|
+
collapsedChainIds: ReadonlySet<string>;
|
|
19
|
+
query: string;
|
|
20
|
+
/** 链路状态筛选(多选并集;空=默认视图)。 */
|
|
21
|
+
statusFilter: ReadonlySet<ChainFilter>;
|
|
22
|
+
onToggleFilter(filter: ChainFilter): void;
|
|
23
|
+
onToggleChain(chainId: string): void;
|
|
24
|
+
onOpenTask(taskId: string): void;
|
|
25
|
+
/** D23:用户 GUI 确认链完成产物归属(POST /kanban/action {type:'confirm-audit'})。 */
|
|
26
|
+
onConfirmAudit?(chainId: string): void;
|
|
27
|
+
/** T7:GUI 链标题改名(POST /kanban/action {type:'rename', chainId, title})。 */
|
|
28
|
+
onRenameChain?(chainId: string, title: string): void;
|
|
29
|
+
/** 整链硬删除(POST /kanban/action {type:'delete', chainId});失败 throw 由弹窗展示。 */
|
|
30
|
+
onDeleteChain?(chainId: string): Promise<void>;
|
|
31
|
+
}) {
|
|
32
|
+
const searching = props.query.trim().length > 0;
|
|
33
|
+
const visible = props.chains.filter((view) => matches(view, props.query));
|
|
34
|
+
const [renamingChainId, setRenamingChainId] = useState<string | null>(null);
|
|
35
|
+
const renamingChain = renamingChainId ? props.chains.find((v) => v.chain.id === renamingChainId)?.chain : undefined;
|
|
36
|
+
const [deletingChainId, setDeletingChainId] = useState<string | null>(null);
|
|
37
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
38
|
+
const deletingView = deletingChainId ? props.chains.find((v) => v.chain.id === deletingChainId) : undefined;
|
|
39
|
+
const confirmDelete = async () => {
|
|
40
|
+
if (!deletingChainId) return;
|
|
41
|
+
setDeleteError(null);
|
|
42
|
+
try {
|
|
43
|
+
await props.onDeleteChain?.(deletingChainId);
|
|
44
|
+
setDeletingChainId(null);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setDeleteError(String(err));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return (
|
|
50
|
+
<div className="dsh-kb-rail">
|
|
51
|
+
<div className="dsh-kb-filters" role="group" aria-label="按链路状态筛选">
|
|
52
|
+
{CHAIN_FILTERS.map((f) => {
|
|
53
|
+
const active = props.statusFilter.has(f);
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
key={f}
|
|
57
|
+
type="button"
|
|
58
|
+
className={`dsh-kb-filter${active ? ' dsh-kb-filter--active' : ''}`}
|
|
59
|
+
aria-pressed={active}
|
|
60
|
+
onClick={() => props.onToggleFilter(f)}
|
|
61
|
+
>
|
|
62
|
+
{CHAIN_FILTER_LABEL[f]}
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
<div className="dsh-kb-rail__list" role="list" aria-label="任务链路">
|
|
68
|
+
{visible.length === 0 ? (
|
|
69
|
+
<div className="dsh-kb-empty" role="status">
|
|
70
|
+
{searching ? '无匹配链路' : '暂无看板任务,输入 /plan: 开启新链路'}
|
|
71
|
+
</div>
|
|
72
|
+
) : visible.map((view) => {
|
|
73
|
+
const matched = searching ? view.tasks.filter((item) => item.task.title.toLowerCase().includes(props.query.trim().toLowerCase())) : view.tasks;
|
|
74
|
+
const expanded = searching || !props.collapsedChainIds.has(view.chain.id);
|
|
75
|
+
const done = view.tasks.filter((item) => item.task.status === 'done' || item.task.status === 'archived').length;
|
|
76
|
+
const blocked = view.tasks.some((item) => item.task.status === 'blocked' || item.task.status === 'failed');
|
|
77
|
+
const summary = view.blockedSummary ?? (blocked ? '链路受阻' : `${done}/${view.tasks.length}`);
|
|
78
|
+
return (
|
|
79
|
+
<section key={view.chain.id} className={`dsh-kb-chain dsh-kb-chain--${view.chain.status}`}>
|
|
80
|
+
<div
|
|
81
|
+
role="button"
|
|
82
|
+
tabIndex={0}
|
|
83
|
+
className="dsh-kb-chain__title"
|
|
84
|
+
aria-expanded={expanded}
|
|
85
|
+
onClick={() => props.onToggleChain(view.chain.id)}
|
|
86
|
+
onKeyDown={(e) => {
|
|
87
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onToggleChain(view.chain.id); }
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<span className="dsh-kb-chain__chevron" aria-hidden="true" />
|
|
91
|
+
<span className="dsh-kb-chain__name">{view.chain.title}</span>
|
|
92
|
+
<span className="dsh-kb-chain__meta">{done}/{view.tasks.length}</span>
|
|
93
|
+
{props.onDeleteChain && (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className="dsh-kb-chain__delete"
|
|
97
|
+
aria-label="删除需求"
|
|
98
|
+
onClick={(e) => { e.stopPropagation(); setDeleteError(null); setDeletingChainId(view.chain.id); }}
|
|
99
|
+
onKeyDown={(e) => { e.stopPropagation(); }}
|
|
100
|
+
>
|
|
101
|
+
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
|
102
|
+
<path d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9.5h6.6L12 4" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
|
103
|
+
<path d="M6.6 6.5v4.5M9.4 6.5v4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
104
|
+
</svg>
|
|
105
|
+
</button>
|
|
106
|
+
)}
|
|
107
|
+
{props.onRenameChain && (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className="dsh-kb-chain__rename"
|
|
111
|
+
aria-label="改链标题"
|
|
112
|
+
onClick={(e) => { e.stopPropagation(); setRenamingChainId(view.chain.id); }}
|
|
113
|
+
onKeyDown={(e) => { e.stopPropagation(); }}
|
|
114
|
+
>
|
|
115
|
+
✎
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
{(blocked || view.blockedSummary) && (
|
|
120
|
+
<div className="dsh-kb-chain__warning">{summary}</div>
|
|
121
|
+
)}
|
|
122
|
+
{/* D23:completed 链存在未确认 audit-warning → 顶部警告行 + 确认按钮(阻塞最终汇报直至确认) */}
|
|
123
|
+
{view.audit && !view.audit.confirmed && (
|
|
124
|
+
<div className="dsh-kb-chain__warning dsh-kb-chain__audit">
|
|
125
|
+
<span>⚠ 主 agent 疑似越权写工作区产物({view.audit.evidenceCount} 条线索),最终汇报已阻塞,请核对产物归属</span>
|
|
126
|
+
<button type="button" className="dsh-kb-audit-confirm" onClick={() => props.onConfirmAudit?.(view.chain.id)}>
|
|
127
|
+
确认产物归属
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{expanded && (
|
|
132
|
+
<ol className="dsh-kb-nodes">
|
|
133
|
+
{(matched.length > 0 ? matched : view.tasks).map((item) => (
|
|
134
|
+
<li key={item.task.id} className={`dsh-kb-node dsh-kb-node--${item.lineState}`}>
|
|
135
|
+
<BoardCard view={item} onOpen={props.onOpenTask} />
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ol>
|
|
139
|
+
)}
|
|
140
|
+
</section>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</div>
|
|
144
|
+
{renamingChain && props.onRenameChain && (
|
|
145
|
+
<RenameModal
|
|
146
|
+
title="改链标题"
|
|
147
|
+
initialValue={renamingChain.title}
|
|
148
|
+
onSave={(title) => { props.onRenameChain?.(renamingChain.id, title); setRenamingChainId(null); }}
|
|
149
|
+
onCancel={() => setRenamingChainId(null)}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
{deletingView && props.onDeleteChain && (
|
|
153
|
+
<div className="dsh-kb-rename-overlay" onClick={(e) => { e.stopPropagation(); setDeletingChainId(null); }}>
|
|
154
|
+
<div
|
|
155
|
+
className="dsh-kb-rename-modal"
|
|
156
|
+
role="dialog"
|
|
157
|
+
aria-modal="true"
|
|
158
|
+
aria-label="删除需求"
|
|
159
|
+
onClick={(e) => e.stopPropagation()}
|
|
160
|
+
>
|
|
161
|
+
<div className="dsh-kb-rename-modal__label">删除需求</div>
|
|
162
|
+
<div className="dsh-kb-delete-modal__text">
|
|
163
|
+
将永久删除「{deletingView.chain.title || '未命名需求'}」及其下 {deletingView.tasks.length} 张角色卡,不可恢复。确认删除?
|
|
164
|
+
</div>
|
|
165
|
+
{deleteError && <div className="dsh-kb-delete-modal__error" role="alert">删除失败:{deleteError}</div>}
|
|
166
|
+
<div className="dsh-kb-rename-modal__actions">
|
|
167
|
+
<button type="button" className="dsh-kb-rename-cancel" onClick={() => setDeletingChainId(null)}>取消</button>
|
|
168
|
+
<button type="button" className="dsh-kb-delete-confirm" onClick={() => void confirmDelete()}>删除</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|